Compare commits

..

69 Commits

Author SHA1 Message Date
xinadmin 3005646179 打包前端 2026-09-07 23:28:54 +08:00
xinadmin edf5e42c38 售后金额上浮 2026-09-07 23:27:56 +08:00
xinadmin 58c733baa2 账单总金额 2026-09-07 23:07:12 +08:00
xinadmin 7de51391e4 批量对账 2026-09-07 22:53:50 +08:00
xinadmin 379aeac2b3 前端打包 2026-09-07 12:25:59 +08:00
xinadmin a636bd31b5 前端打包 2026-09-07 12:21:40 +08:00
xinadmin b784210012 账单样式 2026-09-07 12:21:01 +08:00
xinadmin 51ccaaf11a 账单价格 2026-09-06 23:20:00 +08:00
xinadmin 7a895c143e 对账标记 2026-09-06 22:48:45 +08:00
xinadmin f9eb6f7d32 导出时间 2026-09-06 22:37:46 +08:00
xinadmin f8e81cb830 导出 2026-09-06 22:34:25 +08:00
xinadmin 52f396c12e 截单时间 2026-09-05 19:40:52 +08:00
xinadmin b1bb40a088 排序 2026-09-05 15:17:58 +08:00
xinadmin 12cbed411b 默认重量 2026-09-05 14:37:22 +08:00
xinadmin 364471a086 前端打包 2026-09-05 14:05:16 +08:00
xinadmin 5c7a5829af 导出优化 2026-09-05 14:03:23 +08:00
xinadmin 17c1281073 回调优化 2026-09-04 23:30:13 +08:00
xinadmin 968054b9ff 排序 2026-09-04 23:05:31 +08:00
xinadmin c33247f214 排序 2026-09-04 22:42:50 +08:00
xinadmin 1e25459676 排序 2026-09-04 22:10:28 +08:00
xinadmin cf1d3a6229 微信公众号支付 2026-09-04 21:22:46 +08:00
xinadmin 52e339cdd3 应用设置优化 2026-09-04 20:37:34 +08:00
xinadmin c986c86ed5 导出样式 2026-09-03 22:00:58 +08:00
xinadmin 1370926780 小程序支付 2026-09-03 20:36:09 +08:00
xinadmin d190bdaade 支付优化 2026-09-01 12:47:02 +08:00
xinadmin 95ea0714ad 测试 2026-09-01 11:07:00 +08:00
xinadmin 9c59f3bc99 推荐商品 2026-08-31 22:21:59 +08:00
xinadmin 6c809d893b 采购单修改 2026-08-31 21:45:50 +08:00
xinadmin f8cc40a609 样式修改 2026-08-31 20:48:11 +08:00
xinadmin d0ce897a0f 售后金额 2026-08-29 13:54:06 +08:00
xinadmin c3ec416a77 打包前端 2026-08-27 21:17:33 +08:00
xinadmin c35dac0055 在线支付 2026-08-27 21:17:02 +08:00
xinadmin 65eb8ef594 采购单样式修改 2026-08-27 18:57:16 +08:00
xinadmin f3e190f460 购物车悬浮 2026-08-27 18:13:43 +08:00
xinadmin 52dc6efadf 修复BUG 2026-08-27 14:20:09 +08:00
xinadmin 513cc568a9 打包前端 2026-08-23 14:27:24 +08:00
xinadmin 5d58075644 批量删除 2026-08-23 14:26:37 +08:00
xinadmin 01e67e02b5 运营报表 2026-08-21 12:05:31 +08:00
xinadmin e5f849ef4d 前端构建 2026-08-20 16:54:34 +08:00
xinadmin 1dfa084508 数据库迁移优化 2026-08-20 16:52:58 +08:00
xinadmin 4fe4e985f6 布局适配 2026-08-20 16:48:04 +08:00
xinadmin 22237a47e7 添加市场 2026-08-20 15:39:39 +08:00
xinadmin cd7007bc49 商品导入导出 2026-08-20 14:45:06 +08:00
xinadmin 52ab195432 数据库迁移修改 2026-08-20 14:09:20 +08:00
xinadmin c1d490134e 小程序用户改用门店登录 2026-08-20 13:46:04 +08:00
xinadmin 6d9f02d831 采购金额 2026-08-17 18:33:36 +08:00
xinadmin 3dbb9205a4 采购单优化 2026-08-17 18:13:33 +08:00
xinadmin a06c45dc49 客户等级设置 2026-08-17 16:27:15 +08:00
xinadmin 2eb27127c9 打包前端 2026-08-15 00:58:18 +08:00
xinadmin 7517d4a023 首页分析页 2026-08-15 00:58:02 +08:00
xinadmin 8368c51e3f 打包前端 2026-08-15 00:19:00 +08:00
xinadmin cd35213ae6 回筐导出 2026-08-15 00:14:19 +08:00
xinadmin 7c3f6147ac 回筐逻辑修改 2026-08-14 23:47:49 +08:00
xinadmin 4bdaf2a219 修复一些错误 2026-08-14 22:20:13 +08:00
xinadmin 228212f8e3 修复一些错误 2026-08-14 21:28:55 +08:00
xinadmin 398bb98356 小程序导出账单接口 2026-08-14 20:28:07 +08:00
xinadmin 9a0999742f 小程序接口 2026-08-14 18:02:32 +08:00
xinadmin 969eabcd11 移除分类图片 2026-08-14 16:31:18 +08:00
xinadmin f0927e57e9 移除对账 2026-08-14 15:54:53 +08:00
xinadmin 629d28a369 前端商品列表 2026-08-14 15:22:19 +08:00
xinadmin 8b3cef5af0 小程序首页配置 2026-08-14 12:41:18 +08:00
xinadmin 889f987f17 订单支付记录 2026-08-14 01:58:57 +08:00
xinadmin bf8ac68391 回筐、账单完成 2026-08-14 01:19:47 +08:00
xinadmin e35c951a59 账单生成 2026-08-14 00:15:28 +08:00
xinadmin 61a70319a0 优化采购单 2026-08-13 12:34:32 +08:00
xinadmin 099e92b1ff 优化采购单 2026-08-13 10:53:11 +08:00
xinadmin 79e3ed4a8a 导出 2026-08-13 00:35:09 +08:00
xinadmin 91ea91784d 移除旧版导出 2026-08-13 00:13:13 +08:00
xinadmin 6c794d0732 采购单优化 2026-08-13 00:11:44 +08:00
400 changed files with 21352 additions and 7778 deletions
+33
View File
@@ -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
+309
View File
@@ -0,0 +1,309 @@
<?php
namespace App\Exports;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Services\BillDetailService;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
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;
/**
* 门店账单合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
* 表尾汇总商品金额/配送费/附加金额/售后金额/总金额
*/
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;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand */
private array $specialRows = [];
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
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 $lastRow = 1;
/**
* @param Collection<int, BillModel> $bills 勾选账单(已按 bill_date/id 排序,with store
* @param int $categoryId 一级分类ID0=全部)
*/
public function __construct(
private readonly Collection $bills,
private readonly int $categoryId = 0,
) {
}
/**
* 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings
*/
public function collection(): Collection
{
if ($this->rows !== null) {
return $this->rows;
}
$items = app(BillDetailService::class)->mergedItemsOfBills(
$this->bills->pluck('id')->map(static fn ($id): int => (int) $id)->all()
);
$categoryName = '全部';
if ($this->categoryId > 0) {
[$items, $categoryName] = $this->filterByRootCategory($items);
}
$rows = [];
$rowIndex = 0;
// 标题行
$rows[] = ['门店账单合并导出(共 ' . $this->bills->count() . ' 张)'];
$this->specialRows[++$rowIndex] = 'title';
// 范围行:账单号 / 门店 / 分类 / 导出时间
$billNos = $this->bills->pluck('bill_no')->all();
$billNoText = count($billNos) > 8
? implode('、', array_slice($billNos, 0, 8)) . ' 等 ' . count($billNos) . ' 张'
: implode('、', $billNos);
$storeNames = $this->bills
->map(static fn (BillModel $bill): string => $bill->store->name ?? ('门店#' . $bill->store_id))
->unique()
->values()
->all();
$rows[] = ['账单号:' . $billNoText];
$this->specialRows[++$rowIndex] = 'scope';
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
$rows[] = [''];
$rowIndex++;
// 列头
$rows[] = ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '金额'];
$this->specialRows[++$rowIndex] = 'header';
$this->headerRow = $rowIndex;
// 明细行(跨账单同商品已合并,单价为加权平均)
$totalQuantity = 0;
$totalWeight = '0';
$totalAmount = '0';
foreach (array_values($items) as $sort => $item) {
$rows[] = [
$sort + 1,
$item['product_name'],
$item['product_spec'],
$item['unit'],
(float) $item['price'],
(int) $item['quantity'],
(float) $item['weight'],
(float) $item['amount'],
];
$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'];
$totalWeight = bcadd($totalWeight, $item['weight'], 3);
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
}
// 商品合计行
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$this->specialRows[++$rowIndex] = 'summary';
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $totalQuantity];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $totalAmount];
// 配送费/附加金额/售后金额/总金额(账单级费用全额汇总,不受分类过滤影响)
$deliveryTotal = '0';
$addedTotal = '0';
$afterSaleTotal = '0';
$boxNum = 0;
$trayNum = 0;
foreach ($this->bills as $bill) {
$deliveryTotal = bcadd($deliveryTotal, (string) $bill->delivery_fee, 2);
$addedTotal = bcadd($addedTotal, (string) $bill->added_amount, 2);
$afterSaleTotal = bcadd($afterSaleTotal, (string) $bill->after_sale, 2);
$boxNum += (int) $bill->box_num;
$trayNum += (int) $bill->tray_num;
}
$grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2);
$rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $deliveryTotal];
$rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $addedTotal];
$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->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $grandTotal];
$this->lastRow = $rowIndex;
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计行加粗,合计行标签左合并 A:G;全表居中 + 全边框;金额类列货币格式;
* 数量(浅黄)/金额(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$sheet->getColumnDimension('A')->setWidth(6);
$sheet->getColumnDimension('B')->setWidth(24);
$sheet->getColumnDimension('C')->setWidth(14);
$sheet->getColumnDimension('D')->setWidth(8);
$sheet->getColumnDimension('E')->setWidth(10);
$sheet->getColumnDimension('F')->setWidth(10);
$sheet->getColumnDimension('G')->setWidth(12);
$sheet->getColumnDimension('H')->setWidth(12);
foreach ($this->mergeRows as $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 = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]],
'grand' => ['font' => ['bold' => true, 'size' => 12]],
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);
}
/**
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
* 仅保留根分类为所选分类的行
*
* @param array<int, array> $items 合并后的商品明细
* @return array{0: array<int, array>, 1: string} [过滤后明细, 一级分类名]
*/
private function filterByRootCategory(array $items): array
{
$categories = ProductCategoryModel::all()->keyBy('id');
$categoryName = $categories->get($this->categoryId)->name ?? ('分类#' . $this->categoryId);
$productCategoryIds = ProductModel::withTrashed()
->whereIn('id', array_column($items, 'product_id'))
->pluck('category_id', 'id');
// 商品ID => 根分类ID(与采购单导出同口径的上溯解析)
$rootIds = [];
foreach ($productCategoryIds as $productId => $categoryId) {
$rootId = 0;
$cursor = (int) $categoryId;
$guard = 0;
while ($cursor > 0 && $guard++ < 20) {
$category = $categories->get($cursor);
if ($category === null) {
break;
}
$rootId = (int) $category->id;
$cursor = (int) $category->parent_id;
}
$rootIds[(int) $productId] = $rootId;
}
$categoryId = $this->categoryId;
$filtered = array_values(array_filter(
$items,
static fn (array $row): bool => ($rootIds[$row['product_id']] ?? 0) === $categoryId
));
if ($filtered === []) {
throw new RepositoryException('所选账单在「' . $categoryName . '」分类下无商品明细');
}
return [$filtered, $categoryName];
}
}
+232
View File
@@ -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;
* 末列为行合计,末行为列合计(右下角为总计)
*
* WithStrictNullComparisonPhpSpreadsheet 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);
}
}
+127
View File
@@ -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;
}
}
+296 -111
View File
@@ -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,47 +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;
/** /**
* 采购单导出(C2 全品类按分类 sort 排序 / C3 仅蔬果分类) * 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 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)
@@ -64,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 = '全部';
array_map(static fn (string $name): string => $name . '(数量)', array_values($this->storeNames)), 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),
); );
} $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;
@@ -212,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;
} }
} }
+65
View File
@@ -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;
}
}
+204
View File
@@ -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);
}
}
+77
View File
@@ -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;
}
}
+216
View File
@@ -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);
}
}
-74
View File
@@ -1,74 +0,0 @@
<?php
namespace App\Exports;
use App\Models\ReconciliationItemModel;
use App\Models\SettlementModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* D10 结算表导出(结算头 + 来源对账单中该门店的明细)
*/
class SettlementExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
public function __construct(private readonly SettlementModel $settlement)
{
}
public function collection(): Collection
{
return ReconciliationItemModel::query()
->where('recon_id', $this->settlement->recon_id)
->where('store_id', $this->settlement->store_id)
->orderBy('sort')
->get();
}
public function headings(): array
{
return ['品名', '数量', '称重', '公布金额', '实际金额', '差额', '对账状态', '门店备注'];
}
public function map($item): array
{
return [
$item->product_name,
(float) $item->quantity,
(float) $item->weight,
(float) $item->publish_amount,
(float) $item->actual_amount,
(float) $item->diff_amount,
$item->is_reconciled ? '已对账' : '未对账',
$item->store_remark,
];
}
public function styles(Worksheet $sheet): array
{
$sheet->freezePane('A2');
return [
1 => ['font' => ['bold' => true]],
];
}
/**
* PDF 模板视图数据
*
* @return array{settlement: SettlementModel, storeName: string, reconNo: string, items: Collection}
*/
public function viewData(): array
{
return [
'settlement' => $this->settlement,
'storeName' => $this->settlement->store?->name ?? '',
'reconNo' => $this->settlement->recon?->recon_no ?? '',
'items' => $this->collection(),
];
}
}
+26
View File
@@ -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;
}
}
-67
View File
@@ -1,67 +0,0 @@
<?php
namespace App\Exports;
use App\Models\StatementModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 门店对账单导出
*/
class StatementExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
public function __construct(private readonly StatementModel $statement)
{
}
public function collection(): Collection
{
return $this->statement->items()->orderBy('id')->get();
}
public function headings(): array
{
return ['品名', '单价', '数量', '重量', '金额', '对账状态', '备注'];
}
public function map($item): array
{
return [
$item->product_name,
(float) $item->price,
(float) $item->quantity,
(float) $item->weight,
(float) $item->amount,
$item->is_reconciled ? '已对账' : '未对账',
$item->store_remark,
];
}
public function styles(Worksheet $sheet): array
{
$sheet->freezePane('A2');
return [
1 => ['font' => ['bold' => true]],
];
}
/**
* PDF 模板视图数据
*
* @return array{statement: StatementModel, storeName: string, items: Collection}
*/
public function viewData(): array
{
return [
'statement' => $this->statement,
'storeName' => $this->statement->store?->name ?? '',
'items' => $this->collection(),
];
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Client;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Client\BannerFormRequest;
use App\Models\HomeBannerModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
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;
use Modules\SystemTool\Services\SysFileService;
/**
* 首页轮播图配置(小程序首页)
*/
#[RequestAttribute('/client/banner', 'client.banner')]
class BannerController extends BaseController
{
protected array $searchField = [
'status' => '=',
];
protected array $quickSearchField = ['title'];
/** 轮播图列表(默认按排序升序) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, HomeBannerModel::query())
->orderBy('sort')
->orderBy('id')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 上传轮播图片(文件分组:客户端配置) */
#[PostRoute('/upload', 'create')]
public function uploadImage(Request $request, SysFileService $service): JsonResponse
{
$data = $request->validate(['file' => 'required|file']);
$result = $service->upload(
$data['file'],
12,
20,
Auth::id()
);
return $this->success($result);
}
/** 新增轮播图 */
#[PostRoute(authorize: 'create')]
public function create(BannerFormRequest $request): JsonResponse
{
HomeBannerModel::create($request->validated());
return $this->success();
}
/** 编辑轮播图 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, BannerFormRequest $request): JsonResponse
{
$model = HomeBannerModel::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 = HomeBannerModel::find($id);
if (empty($model)) {
throw new RepositoryException('轮播图不存在');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Client;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Client\NavFormRequest;
use App\Models\HomeNavModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
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;
use Modules\SystemTool\Services\SysFileService;
/**
* 宫格导航配置(小程序首页)
*/
#[RequestAttribute('/client/nav', 'client.nav')]
class NavController extends BaseController
{
protected array $searchField = [
'status' => '=',
];
protected array $quickSearchField = ['name'];
/** 导航列表(默认按排序升序) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, HomeNavModel::query())
->orderBy('sort')
->orderBy('id')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 上传导航图标(文件分组:客户端配置) */
#[PostRoute('/upload', 'create')]
public function uploadImage(Request $request, SysFileService $service): JsonResponse
{
$data = $request->validate(['file' => 'required|file']);
$result = $service->upload(
$data['file'],
12,
20,
Auth::id()
);
return $this->success($result);
}
/** 新增导航 */
#[PostRoute(authorize: 'create')]
public function create(NavFormRequest $request): JsonResponse
{
HomeNavModel::create($request->validated());
return $this->success();
}
/** 编辑导航 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, NavFormRequest $request): JsonResponse
{
$model = HomeNavModel::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 = HomeNavModel::find($id);
if (empty($model)) {
throw new RepositoryException('导航不存在');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Client;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Client\PromoFormRequest;
use App\Models\HomePromoModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
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;
use Modules\SystemTool\Services\SysFileService;
/**
* 促销推荐卡片配置(小程序首页)
*/
#[RequestAttribute('/client/promo', 'client.promo')]
class PromoController extends BaseController
{
protected array $searchField = [
'status' => '=',
];
protected array $quickSearchField = ['title', 'sub_title'];
/** 促销卡片列表(默认按排序升序) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, HomePromoModel::query())
->orderBy('sort')
->orderBy('id')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 上传卡片图片(文件分组:客户端配置) */
#[PostRoute('/upload', 'create')]
public function uploadImage(Request $request, SysFileService $service): JsonResponse
{
$data = $request->validate(['file' => 'required|file']);
$result = $service->upload(
$data['file'],
12,
20,
Auth::id()
);
return $this->success($result);
}
/** 新增促销卡片 */
#[PostRoute(authorize: 'create')]
public function create(PromoFormRequest $request): JsonResponse
{
HomePromoModel::create($request->validated());
return $this->success();
}
/** 编辑促销卡片 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, PromoFormRequest $request): JsonResponse
{
$model = HomePromoModel::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 = HomePromoModel::find($id);
if (empty($model)) {
throw new RepositoryException('促销卡片不存在');
}
$model->delete();
return $this->success();
}
}
@@ -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);
}
}
+1 -39
View File
@@ -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('创建用户失败');
}
} }
+43 -80
View File
@@ -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,58 +4,47 @@ 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;
/** /**
* 小程序端控制器基类 * 小程序端控制器基类
* *
* 无 #[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;
}
/**
* 门店端前置校验:type=门店 且 store_id>0 且门店正常
*/
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; return $store;
} }
/** /**
* 用户当前绑定的正常门店(未绑定/已停用返回 null,不抛错 * 可选登录场景手动识别当前门店(路由关闭 authorize 时使用
* *
* 供商品浏览等「弱前置」场景使用:未绑定门店仍可浏览商品,仅价格不可见 * 手动解析 Bearer token;未携带 token、token 无效或非门店账号时返回 null(不抛错)
*/ */
protected function boundStore(UserModel $user): ?StoreModel protected function optionalStore(Request $request): ?StoreModel
{ {
if ($user->store_id <= 0) { $token = $request->bearerToken();
if (empty($token)) {
return null; return null;
} }
$store = StoreModel::find($user->store_id); $accessToken = SysAccessToken::findToken($token);
if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) { if ($accessToken === null || $accessToken->tokenable_type !== StoreModel::class) {
return null;
}
$store = StoreModel::find($accessToken->tokenable_id);
if ($store === null || $store->status === StoreModel::STATUS_DISABLED) {
return null; return null;
} }
@@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Exports\BillExport;
use App\Models\BillModel;
use App\Services\BillDetailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Symfony\Component\HttpFoundation\Response;
/**
* 小程序门店账单(采购单完成后由后台生成,门店端只读)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class BillController extends BaseMiniController
{
/**
* 账单列表:当前门店强制过滤
* ?status= 支付状态(0未支付 1已支付);?payable=1 仅可发起付款(未支付且未在审核中,供合并付款选择页);
* ?start_date=&end_date= 账单日期区间;?page=&pageSize= 分页(pageSize 上限 50
* 响应附加 summary:待支付笔数/金额(仅按门店口径,不受列表筛选影响)
*/
#[GetRoute('/bill', authorize: true)]
public function index(Request $request): JsonResponse
{
$params = $request->validate([
'status' => 'nullable|integer|in:0,1',
'payable' => 'nullable|boolean',
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
'page' => 'nullable|integer|min:1',
'pageSize' => 'nullable|integer|min:1|max:50',
], [
'status.in' => '支付状态不正确',
'start_date.date_format' => '开始日期格式为 Y-m-d',
'end_date.date_format' => '结束日期格式为 Y-m-d',
'end_date.after_or_equal' => '结束日期不能早于开始日期',
'pageSize.max' => '每页数量最大 50',
]);
$store = $this->currentStore($request);
$query = BillModel::query()
->where('store_id', $store->id)
->with('purchase:id,purchase_no,purchase_date');
if (isset($params['status'])) {
$query->where('status', (int) $params['status']);
}
if ($request->boolean('payable')) {
$query->where('status', BillModel::STATUS_UNPAID)->where('payment_id', 0);
}
if (! empty($params['start_date'])) {
$query->whereDate('bill_date', '>=', $params['start_date']);
}
if (! empty($params['end_date'])) {
$query->whereDate('bill_date', '<=', $params['end_date']);
}
$paginator = $query->orderBy('bill_date', 'desc')
->orderBy('id', 'desc')
->paginate((int) ($params['pageSize'] ?? 10));
$cycleDays = (int) $store->payment_cycle_days;
$paginator->getCollection()->transform(
fn (BillModel $bill): array => $this->formatBill($bill, $cycleDays)
);
// 待支付汇总(合并付款入口的头部统计)
$unpaid = BillModel::query()
->where('store_id', $store->id)
->where('status', BillModel::STATUS_UNPAID)
->selectRaw('COUNT(*) as aggregate_count, COALESCE(SUM(total_amount), 0) as aggregate_amount')
->first();
$data = $paginator->toArray();
$data['summary'] = [
'unpaid_count' => (int) $unpaid->aggregate_count,
'unpaid_amount' => bcadd((string) $unpaid->aggregate_amount, '0', 2),
];
return $this->success($data);
}
/**
* 合并导出账单(静态路由放在 /bill/{id} 之前声明)
* 仅可导出本店账单(强制 store_id 过滤,张数不匹配说明混入无效账单整批拒绝);
* ?ids=1,2,3 必选(1~100 张);?category_id= 一级分类过滤(0/缺省=全部)
*/
#[GetRoute('/bill/export', authorize: true)]
public function export(Request $request): Response
{
$data = $request->validate([
'ids' => 'required|string',
'category_id' => 'nullable|integer|min:0',
]);
$store = $this->currentStore($request);
$ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string) $data['ids'])))));
if ($ids === [] || count($ids) > 100) {
throw new RepositoryException('请选择 1~100 张账单');
}
$bills = BillModel::with('store:id,name')
->where('store_id', $store->id)
->whereIn('id', $ids)
->orderBy('bill_date')
->orderBy('id')
->get();
if ($bills->isEmpty()) {
throw new RepositoryException('账单不存在');
}
if ($bills->count() !== count($ids)) {
throw new RepositoryException('存在无效账单,请刷新后重试');
}
return Excel::download(
new BillExport($bills, (int) ($data['category_id'] ?? 0)),
'门店账单_' . now()->format('Ymd_His') . '.xlsx'
);
}
/** 账单详情(校验归属:仅能查看本店账单;含合并后的商品明细与关联订单) */
#[GetRoute('/bill/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$store = $this->currentStore($request);
$bill = BillModel::with('purchase:id,purchase_no,purchase_date')
->where('store_id', $store->id)
->find($id);
if ($bill === null) {
throw new RepositoryException('账单不存在');
}
$orders = $bill->orders()
->orderBy('id')
->get(['id', 'order_no', 'order_date', 'total_quantity', 'total_weight', 'total_amount', 'status'])
->toArray();
return $this->success([
'bill' => $this->formatBill($bill, (int) $store->payment_cycle_days),
'items' => app(BillDetailService::class)->mergedItems($bill),
'orders' => $orders,
]);
}
/**
* 账单输出格式化(列表行与详情共用)
* 推导支付进度(待支付/审核中/已支付),按门店回款周期计算应结算日期;
* 剔除收款操作人等后台字段
*
* @return array<string, mixed>
*/
private function formatBill(BillModel $bill, int $cycleDays): array
{
$payState = $bill->payState();
return [
'id' => $bill->id,
'bill_no' => $bill->bill_no,
'bill_date' => $bill->bill_date->toDateString(),
'purchase_id' => $bill->purchase_id,
'purchase' => $bill->purchase?->toArray(),
'product_amount' => $bill->product_amount,
'delivery_fee' => $bill->delivery_fee,
'box_num' => $bill->box_num,
'tray_num' => $bill->tray_num,
'box_price' => $bill->box_price,
'tray_price' => $bill->tray_price,
'added_amount' => $bill->added_amount,
'after_sale' => $bill->after_sale,
'total_amount' => $bill->total_amount,
'status' => $bill->status,
'status_name' => BillModel::STATUS_NAMES[$bill->status] ?? '',
'pay_state' => $payState,
'pay_state_name' => BillModel::PAY_STATE_NAMES[$payState],
'can_pay' => $payState === BillModel::PAY_STATE_UNPAID,
'payment_id' => $bill->payment_id,
'settlement_date' => $bill->bill_date->copy()->addDays($cycleDays)->toDateString(),
'paid_at' => $bill->paid_at?->toDateTimeString(),
'pay_remark' => $bill->pay_remark,
'remark' => $bill->remark,
'created_at' => $bill->created_at?->toDateTimeString(),
];
}
}
+30 -41
View File
@@ -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([], '购物车已清空');
} }
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Models\HomeBannerModel;
use App\Models\HomeNavModel;
use App\Models\HomePromoModel;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序首页配置(轮播图 + 宫格导航 + 促销推荐卡片,仅返回启用项)
* 登录门店附加 cart 购物车悬浮球汇总(未登录返回零值结构)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class HomeController extends BaseMiniController
{
/** 首页配置聚合:banners / navs / promos,按 sort 升序 */
#[GetRoute('/home', authorize: false)]
public function index(Request $request): JsonResponse
{
$banners = HomeBannerModel::query()
->where('status', HomeBannerModel::STATUS_NORMAL)
->orderBy('sort')
->orderBy('id')
->get(['id', 'title', 'image_id', 'link', 'sort']);
$navs = HomeNavModel::query()
->where('status', HomeNavModel::STATUS_NORMAL)
->orderBy('sort')
->orderBy('id')
->get(['id', 'name', 'image_id', 'link', 'sort']);
$promos = HomePromoModel::query()
->where('status', HomePromoModel::STATUS_NORMAL)
->orderBy('sort')
->orderBy('id')
->get(['id', 'title', 'sub_title', 'image_id', 'link', 'sort']);
return $this->success([
'banners' => $banners,
'navs' => $navs,
'promos' => $promos,
'cart' => app(CartService::class)->summary($this->optionalStore($request)),
]);
}
}
+20 -17
View File
@@ -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 换 openidwx.requestPayment 调起;
* scene=mp:公众号 H5 网页授权 code 换 openidWeixinJSBridge 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);
}
/**
* 通知应答报文(网关约定格式,timestampyyyyMMddHHmmssSSS
*/
protected function notifyAck(string $code, string $msg): JsonResponse
{
return response()->json([
'code' => $code,
'msg' => mb_substr($msg, 0, 100),
'timestamp' => now()->format('YmdHisv'),
]);
}
}
+174 -41
View File
@@ -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,8 +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,
'product_amount' => $totalAmount,
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'status' => StoreOrderModel::STATUS_PENDING, 'status' => StoreOrderModel::STATUS_PENDING,
'remark' => $remark, 'remark' => $remark,
@@ -119,6 +136,12 @@ class OrderController extends BaseMiniController
} }
StoreOrderItemModel::insert($rows); StoreOrderItemModel::insert($rows);
// 下单成功后自动清空购物车中已下单的商品
CartModel::query()
->where('store_id', $store->id)
->whereIn('product_id', $productIds)
->delete();
return $order; return $order;
}); });
@@ -129,24 +152,97 @@ class OrderController extends BaseMiniController
], '下单成功'); ], '下单成功');
} }
/** 历史订单:当前门店强制过滤,?status=&page=&pageSize= */ /**
* 历史订单:当前门店强制过滤
* ?status= 状态筛选;?start_date=&end_date= 订货日期区间(配合 /order/summary 周期下钻);
* ?page=&pageSize= 分页(pageSize 上限 50
* 行数据附带 status_name / can_cancel / 商品预览(前 3 条明细)/ item_count,完整明细走详情接口
*/
#[GetRoute('/order', authorize: true)] #[GetRoute('/order', authorize: true)]
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$user = $this->currentUser($request); $params = $request->validate([
$store = $this->ensureStoreBound($user); 'status' => 'nullable|integer|in:0,1,2,3,4,9',
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
'page' => 'nullable|integer|min:1',
'pageSize' => 'nullable|integer|min:1|max:50',
], [
'status.in' => '订单状态不正确',
'start_date.date_format' => '开始日期格式为 Y-m-d',
'end_date.date_format' => '结束日期格式为 Y-m-d',
'end_date.after_or_equal' => '结束日期不能早于开始日期',
'pageSize.max' => '每页数量最大 50',
]);
$query = StoreOrderModel::query()->where('store_id', $store->id); $store = $this->currentStore($request);
if ($request->filled('status')) {
$query->where('status', (int) $request->input('status')); $query = StoreOrderModel::query()
->where('store_id', $store->id)
->select([
'id', 'order_no', 'order_date', 'total_quantity', 'total_weight',
'total_amount', 'status', 'remark', 'purchase_id', 'bill_id', 'created_at',
])
->with(['items' => static fn ($itemsQuery) => $itemsQuery
->select(['id', 'order_id', 'product_name', 'product_spec', 'quantity', 'unit', 'image_ids'])
->orderBy('id')]);
if (isset($params['status'])) {
$query->where('status', (int) $params['status']);
}
if (! empty($params['start_date'])) {
$query->whereDate('order_date', '>=', $params['start_date']);
}
if (! empty($params['end_date'])) {
$query->whereDate('order_date', '<=', $params['end_date']);
} }
$data = $query->orderBy('order_date', 'desc') $paginator = $query->orderBy('order_date', 'desc')
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10)) ->paginate((int) ($params['pageSize'] ?? 10));
->toArray();
return $this->success($data); // 商品预览(每单前 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(
static fn (StoreOrderModel $order): array => [
'id' => $order->id,
'order_no' => $order->order_no,
'order_date' => $order->order_date->toDateString(),
'status' => $order->status,
'status_name' => StoreOrderModel::STATUS_NAMES[$order->status] ?? '',
'can_cancel' => $order->status === StoreOrderModel::STATUS_PENDING,
'total_quantity' => $order->total_quantity,
'total_weight' => $order->total_weight,
'total_amount' => $order->total_amount,
'remark' => $order->remark,
'purchase_id' => $order->purchase_id,
'bill_id' => $order->bill_id,
'created_at' => $order->created_at?->toDateTimeString(),
'item_count' => $order->items->count(),
'items' => $previewMap[$order->id] ?? [],
]
);
return $this->success($paginator->toArray());
} }
/** /**
@@ -160,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();
@@ -199,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)
@@ -216,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) {
@@ -232,4 +325,44 @@ class OrderController extends BaseMiniController
return $this->success([], '订单已取消'); return $this->success([], '订单已取消');
} }
/**
* 截单时间校验:业务配置 services.order_time_start / order_time_endHH:mm
* 均留空不限制;只配一端按单边限制;开始时间晚于截单时间表示跨天时段(如 20:00-次日06:00);
* 格式非法的配置按未配置处理,避免误配置导致全天无法下单
*/
private function assertWithinOrderTimeWindow(): void
{
$parse = static function (mixed $value): ?int {
$value = trim((string) $value);
if (! preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $value, $matches)) {
return null;
}
return (int) $matches[1] * 60 + (int) $matches[2];
};
$startText = trim((string) site_config('services.order_time_start', ''));
$endText = trim((string) site_config('services.order_time_end', ''));
$start = $parse($startText);
$end = $parse($endText);
if ($start === null && $end === null) {
return;
}
if ($start !== null && $start === $end) {
return;
}
$now = (int) now()->format('H') * 60 + (int) now()->format('i');
$allowed = match (true) {
// 跨天时段:当晚开始时间之后 或 次日截单时间之前
$start !== null && $end !== null && $start > $end => $now >= $start || $now <= $end,
$start !== null && $end !== null => $now >= $start && $now <= $end,
$start !== null => $now >= $start,
default => $now <= $end,
};
if (! $allowed) {
$window = ($startText !== '' ? $startText : '00:00') . ' - ' . ($endText !== '' ? $endText : '24:00');
throw new RepositoryException('当前不在下单时段内(下单时间 ' . $window . '),请在规定时间内下单');
}
}
} }
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\PaymentModel;
use App\Services\BillNumberService;
use App\Services\WechatMpService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\SystemTool\Models\SysFileModel;
use Throwable;
/**
* 小程序门店支付(选择本店账单合并付款,提交汇款凭证,后台审核通过后账单置已支付)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class PaymentController extends BaseMiniController
{
/** 支付配置:收款码图片与对公汇款信息(付款页展示) */
#[GetRoute('/payment/config', authorize: true)]
public function config(): JsonResponse
{
// 配置值支持图片URL或文件ID(文件ID解析为预览地址)
$resolve = static function (mixed $value): string {
$value = trim((string) $value);
if ($value === '') {
return '';
}
if (is_numeric($value)) {
return (string) (SysFileModel::query()->find((int) $value)?->preview_url ?? '');
}
return $value;
};
// 支付方式开关(Checkbox 配置自动转数组;未配置时默认全部启用)
$payMethods = site_config('pay.pay_methods');
if (! is_array($payMethods)) {
$payMethods = ['wechat_qrcode', 'alipay_qrcode', 'bank', 'online'];
}
return $this->success([
'pay_methods' => array_values($payMethods),
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
'bank_info' => (string) site_config('pay.bank_info', ''),
// 公众号 AppID(H5 在微信内置浏览器拼网页授权链接使用,授权回调 code 传给 /mini/payment/online scene=mp
'mp_appid' => app(WechatMpService::class)->appid(),
]);
}
/** 支付记录列表:当前门店强制过滤,?status=&page=&pageSize= */
#[GetRoute('/payment', authorize: true)]
public function index(Request $request): JsonResponse
{
$store = $this->currentStore($request);
$query = PaymentModel::query()
->where('store_id', $store->id)
->withCount('bills');
if ($request->filled('status')) {
$query->where('status', (int) $request->input('status'));
}
$data = $query->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/**
* 发起付款:合并选择本店未支付账单,提交支付方式与汇款凭证(后台审核)
* @throws Throwable
*/
#[PostRoute('/payment', authorize: true)]
public function create(Request $request): JsonResponse
{
$data = $request->validate([
'bill_ids' => 'required|array|min:1',
'bill_ids.*' => 'integer|distinct',
'pay_method' => 'required|integer|in:1,2,3',
'voucher_ids' => 'required|array|min:1',
'voucher_ids.*' => 'integer|distinct',
'remark' => 'nullable|string|max:255',
], [
'bill_ids.required' => '请选择要付款的账单',
'bill_ids.min' => '请选择要付款的账单',
'pay_method.required' => '请选择支付方式',
'pay_method.in' => '支付方式不正确',
'voucher_ids.required' => '请上传汇款凭证',
'voucher_ids.min' => '请上传汇款凭证',
'remark.max' => '备注超过最大长度',
]);
$store = $this->currentStore($request);
$billIds = array_map('intval', $data['bill_ids']);
$payment = DB::transaction(function () use ($store, $data, $billIds) {
$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'
);
$payment = PaymentModel::create([
'payment_no' => app(BillNumberService::class)->make('ZF'),
'store_id' => $store->id,
'amount' => $amount,
'pay_method' => (int) $data['pay_method'],
'voucher_ids' => array_map('intval', $data['voucher_ids']),
'status' => PaymentModel::STATUS_PENDING,
'remark' => (string) ($data['remark'] ?? ''),
]);
// 锁定账单到本支付记录(审核拒绝后释放,可重新付款)
BillModel::query()->whereIn('id', $bills->pluck('id'))->update(['payment_id' => $payment->id]);
return $payment;
});
return $this->success([
'id' => $payment->id,
'payment_no' => $payment->payment_no,
'amount' => $payment->amount,
], '付款申请已提交,请等待商家审核');
}
/** 支付记录详情(校验归属;含合并账单与凭证图片) */
#[GetRoute('/payment/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$store = $this->currentStore($request);
$payment = PaymentModel::query()
->where('store_id', $store->id)
->find($id);
if ($payment === null) {
throw new RepositoryException('支付记录不存在');
}
$bills = $payment->bills()
->orderBy('id')
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'after_sale', 'total_amount', 'status'])
->toArray();
$data = $payment->toArray();
$data['voucher_urls'] = $payment->voucherUrls();
return $this->success([
'payment' => $data,
'bills' => $bills,
]);
}
}
+79 -61
View File
@@ -3,77 +3,39 @@
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;
use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\AnnoRoute\Attribute\RequestAttribute;
/** /**
* 小程序商品(分类树 + 列表) * 小程序商品
*
* 商品浏览仅需登录:未绑定门店/门店未设客户等级的用户也可查看商品,仅价格不可见(price=null);
* 加购、下单仍由购物车/订单前置校验拦截,要求绑定门店并已设客户等级。
*/ */
#[RequestAttribute('/mini', 'mini', authGuard: 'users')] #[RequestAttribute('/mini', 'mini', authGuard: 'users')]
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
{ {
$activeCategoryIds = ProductModel::query() return $this->success(ProductCategoryModel::getTreeData(['*'], true));
->where('status', ProductModel::STATUS_ON)
->distinct()
->pluck('category_id')
->map(static fn ($id) => (int) $id)
->filter(static fn (int $id) => $id > 0);
$categories = ProductCategoryModel::query()
->where('status', ProductCategoryModel::STATUS_NORMAL)
->get()
->keyBy('id');
// 保留有上架商品的分类 + 其全部祖先
$keep = [];
foreach ($activeCategoryIds as $categoryId) {
$cursor = $categoryId;
$guard = 0;
while ($cursor > 0 && $guard++ < 20 && $categories->has($cursor)) {
$keep[$cursor] = true;
$cursor = (int) $categories[$cursor]->parent_id;
}
}
$filtered = array_values(array_filter(
$categories->toArray(),
static fn (array $item) => isset($keep[$item['id']])
));
return $this->success(ProductCategoryModel::buildTree($filtered));
} }
/** /**
* 商品列表:价格取当前门店客户等级价 * 商品列表(登录门店附 cart_id/cart_quantity 供列表直接加减购物车
* 未绑定门店/门店未设客户等级 → 仍可浏览,price 为 null(不可见价格,加购/下单另由前置校验拦截); * data.cart 为购物车悬浮球汇总,未登录返回零值结构)
* ?category_id=&keyword=&page=&pageSize=
*/ */
#[GetRoute('/product/list', authorize: true)] #[GetRoute('/product/list', authorize: false)]
public function products(Request $request): JsonResponse public function products(Request $request): JsonResponse
{ {
$user = $this->currentUser($request);
$levelId = $this->boundStore($user)?->level_id ?? 0;
$query = ProductModel::query() $query = ProductModel::query()
->where('status', ProductModel::STATUS_ON) ->where('status', ProductModel::STATUS_ON)
->with('category:id,name'); ->with('category:id,name');
if ($levelId > 0) {
$query->with(['prices' => static fn ($q) => $q->where('level_id', $levelId)]);
}
$categoryId = (int) $request->input('category_id', 0); $categoryId = (int) $request->input('category_id', 0);
if ($categoryId > 0) { if ($categoryId > 0) {
$query->where('category_id', $categoryId); $query->where('category_id', $categoryId);
@@ -81,25 +43,81 @@ class ProductController extends BaseMiniController
$keyword = trim((string) $request->input('keyword', '')); $keyword = trim((string) $request->input('keyword', ''));
if ($keyword !== '') { if ($keyword !== '') {
$query->where(static function ($q) use ($keyword) { $query->where(static function ($q) use ($keyword) {
$q->where('name', 'like', '%' . $keyword . '%') $q->where('name', 'like', '%' . $keyword . '%');
->orWhere('spec', 'like', '%' . $keyword . '%');
}); });
} }
$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}";
// 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价; }
// 未绑定门店或未设等级价为 null——未加载 prices 时 ?? null 兜底) $query->orderByRaw(
// unset prices 同时移除 price_type/percent,门店端无法反推成本;cost_price 已被 $hidden 过滤 'CASE category_id ' . implode(' ', $cases) . ' ELSE ' . count($categoryIds) . ' END'
foreach ($data['data'] as &$row) { );
$row['price'] = $row['prices'][0]['actual_price'] ?? null;
unset($row['prices']);
} }
$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);
}
}
@@ -1,105 +0,0 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\StatementModel;
use App\Services\ExportService;
use App\Services\StatementGenerateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Symfony\Component\HttpFoundation\Response;
/**
* 小程序门店对账单(自助生成 / 查看 / 导出)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class StatementController extends BaseMiniController
{
/** 对账单列表(当前门店) */
#[GetRoute('/statement', authorize: true)]
public function index(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$data = StatementModel::query()
->where('store_id', $store->id)
->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 生成对账单:快照当前回款周期,settlement_date = period_end + cycle 天 */
#[PostRoute('/statement/generate', authorize: true)]
public function generate(Request $request): JsonResponse
{
$data = $request->validate([
'period_start' => 'required|date_format:Y-m-d',
'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start',
], [
'period_start.required' => '请选择对账周期开始日期',
'period_start.date_format' => '开始日期格式为 Y-m-d',
'period_end.required' => '请选择对账周期结束日期',
'period_end.date_format' => '结束日期格式为 Y-m-d',
'period_end.after_or_equal' => '结束日期不能早于开始日期',
]);
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$statement = app(StatementGenerateService::class)->generate(
$store,
$data['period_start'],
$data['period_end'],
);
return $this->success([
'id' => $statement->id,
'statement_no' => $statement->statement_no,
'total_amount' => $statement->total_amount,
'settlement_date' => $statement->settlement_date?->toDateString(),
], '对账单已生成');
}
/** 对账单详情(校验归属,含单品对账状态标识) */
#[GetRoute('/statement/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$statement = StatementModel::with('items')
->where('store_id', $store->id)
->find($id);
if ($statement === null) {
throw new RepositoryException('对账单不存在');
}
return $this->success($statement->toArray());
}
/** 导出对账单:?format=xlsx|pdf */
#[GetRoute('/statement/{id}/export', authorize: true, where: ['id' => '[0-9]+'])]
public function export(int $id, Request $request): Response
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$statement = StatementModel::where('store_id', $store->id)->find($id);
if ($statement === null) {
throw new RepositoryException('对账单不存在');
}
return app(ExportService::class)->download(
'statement',
$statement,
(string) $request->query('format', ExportService::FORMAT_XLSX),
);
}
}
@@ -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);
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Mini;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\SystemTool\Services\SysFileService;
/**
* 小程序文件上传(汇款凭证等图片)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class UploadController extends BaseMiniController
{
/** 上传图片,返回文件ID与预览地址(5MB 内) */
#[PostRoute('/upload', authorize: true)]
public function upload(Request $request): JsonResponse
{
$data = $request->validate([
'file' => 'required|image|max:5120',
], [
'file.required' => '请选择要上传的图片',
'file.image' => '仅支持图片文件',
'file.max' => '图片不能超过 5MB',
]);
$store = $this->currentStore($request);
// 分组 4=用户上传,渠道 20=APP用户
$result = app(SysFileService::class)->upload($data['file'], 4, 20, $store->id);
return $this->success([
'id' => $result['id'],
'url' => $result['preview_url'] ?? '',
], '上传成功');
}
}
@@ -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,6 +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',
'bill:id,bill_no,bill_date,total_amount,status,payment_id,paid_at',
]); ]);
// 按包含的商品名称搜索:任一明细品名包含关键字即命中 // 按包含的商品名称搜索:任一明细品名包含关键字即命中
@@ -49,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')
@@ -64,17 +76,10 @@ 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);
$box_amount = site_config('services.box_amount');
$tray_amount = site_config('services.tray_amount');
foreach ($data['data'] as &$item) {
$item['box_price'] = number_format($box_amount, 2);
$item['tray_price'] = number_format($tray_amount, 2);
$item['box_amount'] = number_format($box_amount * $item['box_num'], 2);
$item['tray_amount'] = number_format($tray_amount * $item['tray_num'], 2);
}
return $this->success($data); return $this->success($data);
} }
@@ -85,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('订单不存在');
@@ -95,14 +101,10 @@ class StoreOrderController extends BaseController
$data = $order->toArray(); $data = $order->toArray();
// 明细首图 + 附加金额(与列表接口一致) // 明细首图
app(ItemImageResolver::class)->resolve($data['items']); app(ItemImageResolver::class)->resolve($data['items']);
$boxAmount = site_config('services.box_amount'); // 关联账单详情:补充支付进度
$trayAmount = site_config('services.tray_amount'); $this->appendBillPayState($data);
$data['box_price'] = number_format($boxAmount, 2);
$data['tray_price'] = number_format($trayAmount, 2);
$data['box_amount'] = number_format($boxAmount * $data['box_num'], 2);
$data['tray_amount'] = number_format($trayAmount * $data['tray_num'], 2);
return $this->success($data); return $this->success($data);
} }
@@ -114,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);
@@ -149,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'];
@@ -206,63 +208,12 @@ class StoreOrderController extends BaseController
return $this->success(); return $this->success();
} }
/**
* 修改周转框/周转托盘数量
*/
#[PutRoute(route: '/{id}/container', authorize: 'update', where: ['id' => '[0-9]+'])]
public function container(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'box_num' => 'required|integer|min:0',
'tray_num' => 'required|integer|min:0',
], [
'box_num.required' => '周转框数量不能为空',
'box_num.integer' => '周转框数量必须为整数',
'box_num.min' => '周转框数量不能小于 0',
'tray_num.required' => '周转托盘数量不能为空',
'tray_num.integer' => '周转托盘数量必须为整数',
'tray_num.min' => '周转托盘数量不能小于 0',
]);
$order = StoreOrderModel::find($id);
if (empty($order)) {
throw new RepositoryException('订单不存在');
}
$editable = [
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_DELIVERING,
StoreOrderModel::STATUS_DISTRIBUTION,
];
if (! in_array($order->status, $editable, true)) {
throw new RepositoryException(
'订单当前状态为「' . (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改周转框/托盘数量'
);
}
$boxPrice = (float) site_config('services.box_amount', 0);
$trayPrice = (float) site_config('services.tray_amount', 0);
$addedAmount = round($data['box_num'] * $boxPrice + $data['tray_num'] * $trayPrice, 2);
// 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立
$productAmount = (float) $order->product_amount;
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
}
$order->box_num = (int) $data['box_num'];
$order->tray_num = (int) $data['tray_num'];
$order->product_amount = $productAmount;
$order->added_amount = $addedAmount;
$order->total_amount = round($productAmount + $addedAmount, 2);
$order->save();
return $this->success();
}
/** /**
* 状态流转合法路径 * 状态流转合法路径
* *
* 手动流转仅保留「接单」「取消订单」;后续状态由业务链路自动推进:
* 生成采购单 → 采购中;采购单完成 → 配送中;生成账单 → 已完成
*
* @return int[] * @return int[]
*/ */
private function allowedTransitions(int $status): array private function allowedTransitions(int $status): array
@@ -272,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];
} }
} }
@@ -7,15 +7,12 @@ use App\Http\Requests\Product\ProductCategoryFormRequest;
use App\Models\ProductCategoryModel; use App\Models\ProductCategoryModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
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;
use Modules\AnnoRoute\Attribute\PutRoute; 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;
/** /**
* 商品分类管理(多级分类:蔬菜/水果/其他) * 商品分类管理(多级分类:蔬菜/水果/其他)
@@ -37,34 +34,17 @@ class ProductCategoryController extends BaseController
return $this->success(ProductCategoryModel::getTreeData(['id', 'name', 'parent_id'], true)); return $this->success(ProductCategoryModel::getTreeData(['id', 'name', 'parent_id'], true));
} }
/** 上传商品分类图片文件 */
#[PostRoute('/upload', 'create')]
public function uploadImage(Request $request, SysFileService $service): JsonResponse
{
$data = $request->validate(['file' => 'required|file']);
$result = $service->upload(
$data['file'],
9,
20,
Auth::id()
);
return $this->success($result);
}
/** 创建分类 */ /** 创建分类 */
#[PostRoute(authorize: 'create')] #[PostRoute(authorize: 'create')]
public function create(ProductCategoryFormRequest $request): JsonResponse public function create(ProductCategoryFormRequest $request): JsonResponse
{ {
$validated = $request->validated(); $validated = $request->validated();
$parentId = (int) $validated['parent_id']; $this->assertParentIsTopLevel((int) $validated['parent_id']);
if ($parentId > 0 && ! ProductCategoryModel::whereKey($parentId)->exists()) {
throw new RepositoryException('父级分类不存在');
}
ProductCategoryModel::create($validated); ProductCategoryModel::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, ProductCategoryFormRequest $request): JsonResponse public function update(int $id, ProductCategoryFormRequest $request): JsonResponse
{ {
@@ -73,7 +53,12 @@ class ProductCategoryController extends BaseController
throw new RepositoryException('分类不存在'); throw new RepositoryException('分类不存在');
} }
$validated = $request->validated(); $validated = $request->validated();
$this->assertNoCycle($id, (int) $validated['parent_id']); $parentId = (int) $validated['parent_id'];
$this->assertNoCycle($id, $parentId);
$this->assertParentIsTopLevel($parentId);
if ($parentId > 0 && ProductCategoryModel::where('parent_id', $id)->exists()) {
throw new RepositoryException('该分类下存在子分类,不能调整为子分类');
}
$model->update($validated); $model->update($validated);
return $this->success(); return $this->success();
} }
@@ -96,6 +81,23 @@ class ProductCategoryController extends BaseController
return $this->success(); return $this->success();
} }
/**
* 校验父级分类:存在且必须为顶级分类(分类最多二级)
*/
private function assertParentIsTopLevel(int $parentId): void
{
if ($parentId === 0) {
return;
}
$parent = ProductCategoryModel::whereKey($parentId)->first(['id', 'parent_id']);
if ($parent === null) {
throw new RepositoryException('父级分类不存在');
}
if ((int) $parent->parent_id !== 0) {
throw new RepositoryException('最多支持二级分类,不能选择子分类作为上级');
}
}
/** /**
* 沿父链向上检查,防止 parent_id 指向自身或子孙分类形成环 * 沿父链向上检查,防止 parent_id 指向自身或子孙分类形成环
*/ */
+124 -117
View File
@@ -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 批量调价:批量调整成本价(等级售价随之按上浮比例联动),事务写入,
* 写完后给受影响门店生成 Noticetype=price * 写完后给全部正常门店的用户生成 Noticetype=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']);
}
} }
@@ -3,28 +3,42 @@
namespace App\Http\Controllers\Purchase; namespace App\Http\Controllers\Purchase;
use App\Exceptions\RepositoryException; use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport;
use App\Exports\PurchaseStoreExport;
use App\Exports\PurchaseSupplierExport;
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\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\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 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;
use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController; use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
use Throwable; use Throwable;
/** /**
* 采购单管理C1 汇总生成 / C2-C3 导出 / C4 修改) * 采购单管理
* 采购单无独立明细表,明细直接溯源门店订货明细(store_order_item.purchase_id
*/ */
#[RequestAttribute('/purchase/order', 'purchase.order')] #[RequestAttribute('/purchase/order', 'purchase.order')]
class PurchaseOrderController extends BaseController class PurchaseOrderController extends BaseController
@@ -41,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)
@@ -93,6 +110,8 @@ class PurchaseOrderController extends BaseController
$quantity = 0; $quantity = 0;
// 采购总重量 // 采购总重量
$weight = '0'; $weight = '0';
// 采购总金额(Σ明细 amount,单价按 金额÷数量 加权)
$amount = '0';
// 门店明细 // 门店明细
$cellsMap = []; $cellsMap = [];
@@ -101,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])) {
@@ -119,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),
@@ -131,6 +153,22 @@ class PurchaseOrderController extends BaseController
[$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']]);
// 门店账单:采购单完成后按门店生成(含软删除门店,保证历史单据可见)
$bills = BillModel::query()
->where('purchase_id', $purchase->id)
->with('store:id,name')
->orderBy('id')
->get()
->map(static function (BillModel $bill): array {
$row = $bill->toArray();
$row['store_name'] = $bill->store->name ?? ('门店#' . $bill->store_id);
$row['order_count'] = $bill->orders()->count();
unset($row['store']);
return $row;
})
->values()
->toArray();
return $this->success([ return $this->success([
'purchase' => $purchase->toArray(), 'purchase' => $purchase->toArray(),
'stores' => $stores->toArray(), 'stores' => $stores->toArray(),
@@ -138,29 +176,70 @@ class PurchaseOrderController extends BaseController
unset($row['category_sort'], $row['product_sort']); unset($row['category_sort'], $row['product_sort']);
return $row; return $row;
}, $rows), }, $rows),
'bills' => $bills,
// 单品「已对账」标记(入库持久化,商品ID列表)
'checked_product_ids' => PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->pluck('product_id')
->map(static fn ($v) => (int) $v)
->all(),
]); ]);
} }
/** C4 修改采购单信息(采购日期、状态、备注) */ /** 修改采购单信息(采购日期、实际金额、备注) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] #[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, Request $request): JsonResponse public function update(int $id, Request $request): JsonResponse
{ {
$data = $request->validate([ $data = $request->validate([
'purchase_date' => 'nullable|date_format:Y-m-d', 'actual_amount' => 'required|numeric|min:0',
'status' => 'nullable|integer|in:' . PurchaseOrderModel::STATUS_PENDING . ',' . PurchaseOrderModel::STATUS_COMPLETED,
'remark' => 'nullable|string|max:255', 'remark' => 'nullable|string|max:255',
], [ ], [
'purchase_date.date_format' => '采购日期格式为 Y-m-d', 'actual_amount.required' => '实际金额不能为空',
'status.in' => '采购单状态不正确', 'actual_amount.numeric' => '实际金额格式错误',
'actual_amount.min' => '实际金额不能小于0',
'remark.max' => '备注超过最大长度',
]); ]);
$purchase = PurchaseOrderModel::find($id); $purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) { if (empty($purchase)) {
throw new RepositoryException('采购单不存在'); throw new RepositoryException('采购单不存在');
} }
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改信息');
}
$purchase->update(array_filter($data, static fn ($v) => $v !== null)); $purchase->update(array_filter($data, static fn ($v) => $v !== null));
return $this->success(); return $this->success();
} }
/**
* 完成采购单
* @throws
*/
#[PutRoute(route: '/{id}/finish', authorize: 'update', where: ['id' => '[0-9]+'])]
public function finish(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改信息');
}
return DB::transaction(function () use ($purchase) {
// 修改采购单状态
$purchase->status = PurchaseOrderModel::STATUS_COMPLETED;
$purchase->save();
// 修改所有订单状态为配送中
StoreOrderModel::where('purchase_id', $purchase->id)->update([
'status' => StoreOrderModel::STATUS_DISTRIBUTION
]);
return $this->success();
});
}
/** /**
* 生成采购单 * 生成采购单
*/ */
@@ -190,6 +269,71 @@ class PurchaseOrderController extends BaseController
); );
} }
/**
* 导出采购单商品明细 Excel 表格(系统全部商品行,支持按供应商筛选)
*
* @throws
*/
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
public function export(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 PurchaseOrderExport($purchase, $supplierId), $filename);
}
/**
* 导出门店购买详情(多工作表:每门店一个工作表;?store_id= 单门店导出)
*/
#[GetRoute(route: '/{id}/exportStores', authorize: 'export', where: ['id' => '[0-9]+'])]
public function exportStores(int $id, Request $request): Response
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$storeId = (int) $request->query('store_id', 0);
$filename = $purchase->purchase_no . '_门店购买详情.xlsx';
if ($storeId > 0) {
$store = StoreModel::withTrashed()->find($storeId);
$filename = $purchase->purchase_no . '_门店购买详情_' . ($store->name ?? ('门店' . $storeId)) . '.xlsx';
}
return Excel::download(new PurchaseStoreExport($purchase, $storeId), $filename);
}
/**
* 导出供应商采购明细(多工作表:每供应商一个工作表;?supplier_id= 单供应商导出)
*/
#[GetRoute(route: '/{id}/exportSuppliers', authorize: 'export', where: ['id' => '[0-9]+'])]
public function exportSuppliers(int $id, Request $request): Response
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$supplierId = (int) $request->query('supplier_id', 0);
$filename = $purchase->purchase_no . '_供应商采购明细.xlsx';
if ($supplierId > 0) {
$supplier = SupplierModel::withTrashed()->find($supplierId);
$filename = $purchase->purchase_no . '_供应商采购明细_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx';
}
return Excel::download(new PurchaseSupplierExport($purchase, $supplierId), $filename);
}
/** /**
* 单元格下钻:采购单中某门店某商品的全部订货明细 * 单元格下钻:采购单中某门店某商品的全部订货明细
*/ */
@@ -243,8 +387,6 @@ class PurchaseOrderController extends BaseController
'amount' => (string) $item->amount, 'amount' => (string) $item->amount,
'remark' => (string) $item->remark, 'remark' => (string) $item->remark,
'image_ids' => $item->image_ids, 'image_ids' => $item->image_ids,
'editable' => $purchase->status === PurchaseOrderModel::STATUS_PENDING
&& in_array((int) $item->order_status, StoreOrderItemModel::ITEM_EDITABLE_STATUS, true),
]; ];
} }
app(ItemImageResolver::class)->resolve($rows); app(ItemImageResolver::class)->resolve($rows);
@@ -260,7 +402,296 @@ class PurchaseOrderController extends BaseController
} }
/** /**
* 商品行修改:品名/供应商/包规/单位/成本 * 门店购买详情:采购单内指定门店的采购汇总(按商品聚合)
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=预计金额
*/
#[GetRoute(route: '/{id}/store', authorize: 'query', where: ['id' => '[0-9]+'])]
public function storeSummary(int $id, Request $request): JsonResponse
{
$storeId = (int) $request->query('store_id', 0);
if ($storeId <= 0) {
throw new RepositoryException('缺少门店参数');
}
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$store = StoreModel::withTrashed()->find($storeId);
return $this->success([
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
'items' => app(PurchaseItemService::class)->storeSummaryRows($purchase->id, $storeId),
]);
}
/**
* 门店购买详情:新增单品(挂靠该门店在采购单中的最新一笔订单,
* 单价按门店等级上浮比例换算,无等级按成本价兜底)
*
* @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([], '已移除单品');
}
/**
* 账单生成预览
*/
#[GetRoute(route: '/{id}/bill/prepare', authorize: 'query', where: ['id' => '[0-9]+'])]
public function billPrepare(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$orders = StoreOrderModel::query()
->where('purchase_id', $purchase->id)
->whereNull('deleted_at')
->orderBy('id')
->get(['id', 'store_id', 'total_amount']);
$stores = StoreModel::withTrashed()
->with('level:id,name,percent')
->whereIn('id', $orders->pluck('store_id')->unique())
->get(['id', 'name', 'level_id'])
->keyBy('id');
$bills = BillModel::query()
->where('purchase_id', $purchase->id)
->get()
->keyBy('store_id');
$boxPrice = number_format((float) site_config('services.box_amount', 0), 2);
$trayPrice = number_format((float) site_config('services.tray_amount', 0), 2);
$rows = [];
foreach ($orders->groupBy('store_id') as $storeId => $storeOrders) {
$productAmount = $storeOrders->reduce(
static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
'0'
);
$bill = $bills->get((int) $storeId);
$store = $stores->get((int) $storeId);
$rows[] = [
'store_id' => (int) $storeId,
'store_name' => $store->name ?? ('门店#' . $storeId),
// 客户等级(售后金额上浮折算预览用;无等级=不上浮)
'level_name' => $store?->level?->name,
'level_percent' => (string) ($store?->level?->percent ?? '0'),
'order_count' => $storeOrders->count(),
'product_amount' => $productAmount,
'box_price' => $boxPrice,
'tray_price' => $trayPrice,
'bill' => $bill?->toArray(),
];
}
return $this->success([
'purchase' => $purchase->only(['id', 'purchase_no', 'status']),
'stores' => $rows,
]);
}
/**
* 生成账单
* @throws Throwable
*/
#[PostRoute(route: '/{id}/bill', authorize: 'bill', where: ['id' => '[0-9]+'])]
public function generateBill(int $id, PurchaseBillGenerateRequest $request): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_COMPLETED) {
throw new RepositoryException('采购单未完成,不允许生成账单');
}
$bills = app(BillGenerateService::class)->generate(
$purchase,
$request->validated()['stores'],
(int) $request->user()->id,
);
return $this->success(['count' => count($bills)], '已生成 ' . count($bills) . ' 张门店账单');
}
/**
* 商品行修改:品名/供应商/包规/单位/成本(同步更新商品档案;商品已删除则跳过档案同步)
* 成本价变化时,按各门店客户等级的上浮比例重算明细单价与金额(无等级按成本价兜底),
* 并级联重算涉及订单与采购单汇总
* @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]+'])]
@@ -270,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);
});
} }
/** /**
@@ -315,32 +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->product_amount = $order->items()->sum('amount'); $service->recalcPurchase($purchase->id);
$order->total_weight = $order->items()->sum('weight');
$order->total_amount = bcadd($order->product_amount, $order->added_amount, 2);
$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('采购单已完成,不允许修改明细');
}
}
} }
@@ -0,0 +1,163 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Exports\BillExport;
use App\Models\BillModel;
use App\Models\StoreModel;
use App\Services\BillDetailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 门店账单管理(采购单完成后按门店生成,后台查看 + 线下收款登记)
*/
#[RequestAttribute('/recon/bill', 'recon.bill')]
class BillController extends BaseController
{
protected array $searchField = [
'store_id' => '=',
'status' => '=',
'bill_no' => 'like',
'bill_date' => 'betweenDate',
];
/** 账单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = BillModel::query()->with([
'store:id,name',
'purchase:id,purchase_no,purchase_date',
'operator:id,nickname',
])->withCount('orders');
// 按采购单号搜索
$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)
->orderBy('bill_date', 'desc')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/**
* 合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
* 表尾汇总商品金额/配送费/附加金额/总金额
*/
#[GetRoute(route: '/export', authorize: 'export')]
public function export(Request $request): Response
{
$data = $request->validate([
'ids' => 'required|string',
'category_id' => 'nullable|integer|min:0',
]);
$ids = array_values(array_filter(array_map('intval', explode(',', (string) $data['ids']))));
if ($ids === [] || count($ids) > 100) {
throw new RepositoryException('请选择 1~100 张账单');
}
$bills = BillModel::with('store:id,name')
->whereIn('id', $ids)
->orderBy('bill_date')
->orderBy('id')
->get();
if ($bills->isEmpty()) {
throw new RepositoryException('账单不存在');
}
return Excel::download(
new BillExport($bills, (int) ($data['category_id'] ?? 0)),
'门店账单_' . now()->format('Ymd_His') . '.xlsx'
);
}
/**
* 账单详情:账单信息 + 合并后的商品明细(按商品聚合)+ 关联订单
*/
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$bill = BillModel::with([
'store:id,name,address,contact,phone',
'purchase:id,purchase_no,purchase_date,status',
'operator:id,nickname',
'paidOperator:id,nickname',
])->find($id);
if (empty($bill)) {
throw new RepositoryException('账单不存在');
}
$orders = $bill->orders()
->orderBy('id')
->get(['id', 'order_no', 'order_date', 'total_quantity', 'total_weight', 'total_amount', 'status'])
->toArray();
return $this->success([
'bill' => $bill->toArray(),
'items' => app(BillDetailService::class)->mergedItems($bill),
'orders' => $orders,
]);
}
/**
* 确认收款:线下收款后手动登记付款信息,支付状态置为已支付;
* 累加门店总采购金额(只统计商品金额)
*/
#[PutRoute(route: '/{id}/pay', authorize: 'pay', where: ['id' => '[0-9]+'])]
public function pay(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'paid_at' => 'sometimes|date_format:Y-m-d H:i:s',
'pay_remark' => 'nullable|string|max:255',
], [
'paid_at.date_format' => '付款时间格式为 Y-m-d H:i:s',
'pay_remark.max' => '付款备注超过最大长度',
]);
return DB::transaction(function () use ($id, $data, $request) {
$bill = BillModel::query()->lockForUpdate()->find($id);
if (empty($bill)) {
throw new RepositoryException('账单不存在');
}
if ($bill->status === BillModel::STATUS_PAID) {
throw new RepositoryException('账单已支付,请勿重复收款');
}
$bill->status = BillModel::STATUS_PAID;
$bill->paid_at = $data['paid_at'] ?? now();
$bill->pay_remark = (string) ($data['pay_remark'] ?? '');
$bill->paid_operator_id = (int) $request->user()->id;
$bill->save();
// 累加门店总采购金额(只统计商品金额,不含配送费/附加金额)
$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([], '收款已登记');
});
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Exports\ContainerReturnExport;
use App\Models\ContainerReturnModel;
use App\Models\StoreModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Maatwebsite\Excel\Facades\Excel;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 压回筐记录(周转筐/托盘跟账单走,生成账单时自动写入完整快照,只读)
*
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额)
*/
#[RequestAttribute('/recon/container-return', 'recon.containerReturn')]
class ContainerReturnController extends BaseController
{
protected array $searchField = [
'store_id' => '=',
'bill_id' => '=',
'created_at' => 'betweenDate',
];
/** 压回筐记录列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, ContainerReturnModel::query()->with([
'store:id,name',
'bill:id,bill_no,bill_date',
'operator:id,nickname',
]))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/**
* 汇总导出:按日期区间 + 门店导出抵扣(附加)金额矩阵,
* 行=日期(同日记录合并),列=门店,含行合计/列合计,当天门店无记录填 0
*/
#[GetRoute(route: '/export', authorize: 'export')]
public function export(Request $request): Response
{
$data = $request->validate([
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'store_ids' => 'nullable|string',
], [
'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 天');
}
$storeIds = array_values(array_filter(
array_map(intval(...), explode(',', (string) ($data['store_ids'] ?? '')))
));
$records = ContainerReturnModel::query()
->whereBetween('created_at', [$startDate . ' 00:00:00', $endDate . ' 23:59:59'])
->when($storeIds !== [], static fn ($query) => $query->whereIn('store_id', $storeIds))
->get(['id', 'store_id', 'amount', 'created_at']);
// 已选门店:列取所选门店(无记录填 0);未选门店:列取区间内有记录的门店
$columnStoreIds = $storeIds !== []
? $storeIds
: $records->pluck('store_id')->unique()->map(static fn ($id): int => (int) $id)->all();
if ($columnStoreIds === []) {
throw new RepositoryException('所选日期区间内无压回筐记录');
}
sort($columnStoreIds);
$names = StoreModel::withTrashed()
->whereIn('id', $columnStoreIds)
->pluck('name', 'id');
$storeNames = [];
foreach ($columnStoreIds as $storeId) {
$storeNames[$storeId] = $names->get($storeId, '门店#' . $storeId);
}
return Excel::download(
new ContainerReturnExport($records, $storeNames, $startDate, $endDate),
'回筐记录_' . $startDate . '_' . $endDate . '.xlsx'
);
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\PaymentModel;
use App\Models\StoreModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Throwable;
/**
* 支付记录(小程序合并付款提交汇款凭证;后台审核:通过后关联账单批量置已支付,拒绝释放账单)
*/
#[RequestAttribute('/recon/payment', 'recon.payment')]
class PaymentController extends BaseController
{
protected array $searchField = [
'store_id' => '=',
'status' => '=',
'pay_type' => '=',
'pay_method' => '=',
'payment_no' => 'like',
];
/** 支付记录列表(待审核优先) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, PaymentModel::query()
->with(['store:id,name', 'auditor:id,nickname'])
->withCount('bills'))
->orderBy('status')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 支付记录详情:支付信息 + 凭证图片 + 合并付款的账单 */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$payment = PaymentModel::with(['store:id,name,contact,phone', 'auditor:id,nickname'])->find($id);
if (empty($payment)) {
throw new RepositoryException('支付记录不存在');
}
$bills = $payment->bills()
->orderBy('id')
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'after_sale', 'total_amount', 'status'])
->toArray();
$data = $payment->toArray();
$data['voucher_urls'] = $payment->voucherUrls();
return $this->success([
'payment' => $data,
'bills' => $bills,
]);
}
/**
* 审核支付记录:通过 → 关联账单全部置已支付;拒绝 → 释放账单(可重新发起付款)
* @throws Throwable
*/
#[PutRoute(route: '/{id}/audit', authorize: 'audit', where: ['id' => '[0-9]+'])]
public function audit(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'result' => 'required|string|in:pass,reject',
'audit_remark' => 'nullable|string|max:255|required_if:result,reject',
], [
'result.required' => '请选择审核结果',
'result.in' => '审核结果不正确',
'audit_remark.required_if' => '拒绝时请填写原因',
'audit_remark.max' => '审核备注超过最大长度',
]);
return DB::transaction(function () use ($id, $data, $request) {
$payment = PaymentModel::query()->lockForUpdate()->find($id);
if (empty($payment)) {
throw new RepositoryException('支付记录不存在');
}
if ($payment->status !== PaymentModel::STATUS_PENDING) {
throw new RepositoryException('该支付记录已审核,请勿重复操作');
}
$bills = $payment->bills()->lockForUpdate()->get();
$auditorId = (int) $request->user()->id;
$now = now();
if ($data['result'] === 'pass') {
// 任一账单已通过其他方式收款(如线下登记)则整批中止,避免重复收款
$paid = $bills->where('status', BillModel::STATUS_PAID);
if ($paid->isNotEmpty()) {
throw new RepositoryException(
'账单 ' . $paid->pluck('bill_no')->implode('、') . ' 已收款,请核实后再审核'
);
}
$methodName = PaymentModel::METHOD_NAMES[$payment->pay_method] ?? '线上支付';
BillModel::query()->whereIn('id', $bills->pluck('id'))->update([
'status' => BillModel::STATUS_PAID,
'paid_at' => $now,
'paid_operator_id' => $auditorId,
'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;
} else {
// 拒绝:释放账单,门店可重新发起付款
BillModel::query()->whereIn('id', $bills->pluck('id'))->update(['payment_id' => 0]);
$payment->status = PaymentModel::STATUS_REJECTED;
}
$payment->audited_at = $now;
$payment->auditor_id = $auditorId;
$payment->audit_remark = (string) ($data['audit_remark'] ?? '');
$payment->save();
return $this->success(
[],
$payment->status === PaymentModel::STATUS_APPROVED
? '审核通过,' . $bills->count() . ' 张账单已置为已支付'
: '已拒绝,账单已释放可重新付款'
);
});
}
}
@@ -1,125 +0,0 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ReconItemUpdateRequest;
use App\Models\ReconciliationItemModel;
use App\Models\ReconciliationModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 对账明细操作(D4 修改 / D6 单品级门店备注 / D8 对账状态标记)
* 权限点前缀 recon.itemauthorize: item.update → recon.item.item.update
*/
#[RequestAttribute('/recon/item', 'recon.item')]
class ReconItemController extends BaseController
{
/**
* D4 修改订货量/称重/数量/金额/商品名,自动重算本行 diff + 头汇总
*/
#[PutRoute(route: '/{id}', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function update(int $id, ReconItemUpdateRequest $request): JsonResponse
{
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$validated = $request->validated();
if (isset($validated['product_name'])) {
$item->product_name = $validated['product_name'];
}
if (isset($validated['quantity'])) {
$item->quantity = $validated['quantity'];
}
if (isset($validated['weight'])) {
$item->weight = $validated['weight'];
}
if (isset($validated['publish_amount'])) {
$item->publish_amount = $validated['publish_amount'];
}
if (isset($validated['actual_amount'])) {
$item->actual_amount = $validated['actual_amount'];
}
// 重算本行差额
$item->diff_amount = bcsub((string) $item->publish_amount, (string) $item->actual_amount, 2);
$item->save();
$this->refreshReconSummary((int) $item->recon_id);
return $this->success(['diff_amount' => $item->diff_amount]);
}
/** D8 对账状态标记翻转 */
#[PutRoute(route: '/{id}/toggle', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function toggle(int $id): JsonResponse
{
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$item->is_reconciled = $item->is_reconciled === ReconciliationItemModel::RECONCILED
? ReconciliationItemModel::NOT_RECONCILED
: ReconciliationItemModel::RECONCILED;
$item->save();
return $this->success(['is_reconciled' => $item->is_reconciled]);
}
/** D6 单品级门店备注 */
#[PutRoute(route: '/{id}/remark', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function remark(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'store_remark' => 'nullable|string|max:255',
], [
'store_remark.max' => '备注最长 255 个字符',
]);
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$item->store_remark = (string) ($data['store_remark'] ?? '');
$item->save();
return $this->success();
}
/**
* 已结算的对账单明细不允许修改
*/
private function assertEditable(ReconciliationItemModel $item): void
{
$recon = ReconciliationModel::find($item->recon_id);
if ($recon !== null && $recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,明细不能修改');
}
}
/**
* 明细变更后重算对账单头汇总(publish / actual / diff
*/
private function refreshReconSummary(int $reconId): void
{
$sums = ReconciliationItemModel::query()
->where('recon_id', $reconId)
->selectRaw('COALESCE(SUM(publish_amount), 0) as publish_total, COALESCE(SUM(actual_amount), 0) as actual_total')
->first();
ReconciliationModel::whereKey($reconId)->update([
'publish_amount' => $sums->publish_total,
'actual_amount' => $sums->actual_total,
'diff_amount' => bcsub((string) $sums->publish_total, (string) $sums->actual_total, 2),
]);
}
}
@@ -1,235 +0,0 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ReconciliationFormRequest;
use App\Models\ReconciliationModel;
use App\Models\SettlementModel;
use App\Services\BillNumberService;
use App\Services\ReconciliationBuildService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
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;
/**
* 财务对账管理(D1 品类 / D2 供应商筛选、D5 差额对比、D9 结算表生成)
* 对账明细的 D4/D6/D8 操作见 ReconItemController
*/
#[RequestAttribute('/recon/list', 'recon.list')]
class ReconciliationController extends BaseController
{
protected array $searchField = [
'status' => '=',
'category_id' => '=',
'supplier_id' => '=',
'title' => 'like',
'period_start' => 'date',
];
/** 对账单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, ReconciliationModel::query()->with('operator:id,nickname'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建对账单(草稿,recon_no = RC…) */
#[PostRoute(authorize: 'create')]
public function create(ReconciliationFormRequest $request): JsonResponse
{
$validated = $request->validated();
$recon = ReconciliationModel::create([
'recon_no' => app(BillNumberService::class)->make('RC'),
'title' => $validated['title'],
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'category_id' => $validated['category_id'],
'supplier_id' => $validated['supplier_id'],
'publish_amount' => 0,
'actual_amount' => 0,
'diff_amount' => 0,
'status' => ReconciliationModel::STATUS_DRAFT,
'operator_id' => (int) $request->user()->id,
'remark' => $validated['remark'] ?? '',
]);
return $this->success(['id' => $recon->id]);
}
/** 编辑对账单(仅草稿/对账中) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, ReconciliationFormRequest $request): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,不能编辑');
}
$validated = $request->validated();
$recon->update([
'title' => $validated['title'],
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'category_id' => $validated['category_id'],
'supplier_id' => $validated['supplier_id'],
'remark' => $validated['remark'] ?? '',
]);
return $this->success();
}
/** 删除对账单(仅草稿可删,连带明细) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status !== ReconciliationModel::STATUS_DRAFT) {
throw new RepositoryException('仅草稿状态的对账单可以删除');
}
DB::transaction(function () use ($recon) {
$recon->items()->delete();
$recon->delete();
});
return $this->success();
}
/** 生成对账明细(按周期 + 品类 + 供应商拉取已完成订单明细;可重复生成) */
#[PostRoute(route: '/{id}/build', authorize: 'build', where: ['id' => '[0-9]+'])]
public function build(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,不能重新生成明细');
}
$count = app(ReconciliationBuildService::class)->build($recon);
return $this->success(['count' => $count], '对账明细已生成');
}
/**
* D5 差额对比视图:按门店 / 按商品两个维度 + 合计行
*/
#[GetRoute(route: '/{id}/diff', authorize: 'query', where: ['id' => '[0-9]+'])]
public function diff(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
$items = $recon->items()->with('store:id,name')->get();
$byStore = $items->groupBy('store_id')->map(function ($group) {
$first = $group->first();
$publish = $group->sum('publish_amount');
$actual = $group->sum('actual_amount');
return [
'store_id' => $first->store_id,
'store_name' => $first->store?->name ?? '',
'publish' => (float) $publish,
'actual' => (float) $actual,
'diff' => (float) bcsub((string) $publish, (string) $actual, 2),
];
})->values();
$byProduct = $items->groupBy('product_id')->map(function ($group) {
$first = $group->first();
$publish = $group->sum('publish_amount');
$actual = $group->sum('actual_amount');
return [
'product_id' => $first->product_id,
'product_name' => $first->product_name,
'publish' => (float) $publish,
'actual' => (float) $actual,
'diff' => (float) bcsub((string) $publish, (string) $actual, 2),
];
})->values();
$publishTotal = (string) $items->sum('publish_amount');
$actualTotal = (string) $items->sum('actual_amount');
return $this->success([
'by_store' => $byStore->toArray(),
'by_product' => $byProduct->toArray(),
'total' => [
'publish' => (float) $publishTotal,
'actual' => (float) $actualTotal,
'diff' => (float) bcsub($publishTotal, $actualTotal, 2),
],
]);
}
/**
* D9 生成结算表:按门店聚合明细生成 settlement 记录,对账单 status → 已结算
* (回框统计表规则待业务确认,本次仅预留结构)
*/
#[PostRoute(route: '/{id}/settle', authorize: 'settle', where: ['id' => '[0-9]+'])]
public function settle(int $id, Request $request): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status !== ReconciliationModel::STATUS_WORKING) {
throw new RepositoryException('仅「对账中」的对账单可以生成结算表');
}
$count = DB::transaction(function () use ($recon, $request) {
$groups = $recon->items()->get()->groupBy('store_id');
if ($groups->isEmpty()) {
throw new RepositoryException('对账单无明细,请先生成对账明细');
}
$billNumber = app(BillNumberService::class);
foreach ($groups as $storeId => $items) {
$publish = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->publish_amount, 2),
'0'
);
$actual = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->actual_amount, 2),
'0'
);
SettlementModel::create([
'settlement_no' => $billNumber->make('JS'),
'recon_id' => $recon->id,
'store_id' => (int) $storeId,
'period_start' => $recon->period_start,
'period_end' => $recon->period_end,
'total_amount' => $publish,
'actual_amount' => $actual,
'diff_amount' => bcsub($publish, $actual, 2),
'status' => SettlementModel::STATUS_SETTLED,
'operator_id' => (int) $request->user()->id,
'settled_at' => now(),
]);
}
$recon->status = ReconciliationModel::STATUS_SETTLED;
$recon->save();
return $groups->count();
});
return $this->success(['count' => $count], '结算表已生成');
}
}
@@ -1,77 +0,0 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Models\SettlementModel;
use App\Services\ExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
*/
#[RequestAttribute('/recon/settlement', 'recon.settlement')]
class SettlementController extends BaseController
{
protected array $searchField = [
'settlement_no' => 'like',
'recon_id' => '=',
'store_id' => '=',
'status' => '=',
];
/** 结算表列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch(
$params,
SettlementModel::query()->with(['store:id,name', 'recon:id,recon_no,title'])
)
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 结算表详情 */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$settlement = SettlementModel::with(['store:id,name', 'recon:id,recon_no,title', 'operator:id,nickname'])
->find($id);
if (empty($settlement)) {
throw new RepositoryException('结算表不存在');
}
return $this->success($settlement->toArray());
}
/**
* D10 导出下载:?format=xlsx|pdf,成功后回写 file_path 存档标记
*/
#[GetRoute(route: '/{id}/download', authorize: 'download', where: ['id' => '[0-9]+'])]
public function download(int $id, Request $request): Response
{
$settlement = SettlementModel::find($id);
if (empty($settlement)) {
throw new RepositoryException('结算表不存在');
}
$format = (string) $request->query('format', ExportService::FORMAT_XLSX);
$response = app(ExportService::class)->download('settlement', $settlement, $format);
// 同步流式下载不落盘,file_path 仅作存档标记(后续切队列导出时替换为真实文件路径)
$extension = $format === ExportService::FORMAT_PDF ? 'pdf' : 'xlsx';
$settlement->file_path = 'exports/settlement/' . $settlement->settlement_no . '.' . $extension;
$settlement->save();
return $response;
}
}
@@ -1,49 +0,0 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Models\StatementModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 门店对账单管理(后台只读视角;生成/导出在小程序端)
*/
#[RequestAttribute('/recon/statement', 'recon.statement')]
class StatementController extends BaseController
{
protected array $searchField = [
'statement_no' => 'like',
'store_id' => '=',
'status' => '=',
'period_start' => 'betweenDate',
];
/** 对账单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, StatementModel::query()->with('store:id,name'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 对账单详情(含明细) */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$statement = StatementModel::with(['store:id,name', 'items'])->find($id);
if (empty($statement)) {
throw new RepositoryException('对账单不存在');
}
return $this->success($statement->toArray());
}
}
-68
View File
@@ -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,35 @@
<?php
namespace App\Http\Requests\Client;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 首页轮播图 创建/编辑 验证
*/
class BannerFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'title' => 'required|string|max:50',
'image_id' => 'required|integer|min:1',
'link' => 'nullable|string|max:255',
'sort' => 'nullable|integer|min:0',
'status' => 'required|integer|in:0,1',
];
}
public function messages(): array
{
return [
'title.required' => '轮播图标题不能为空',
'title.max' => '轮播图标题最长 50 个字符',
'image_id.required' => '请上传轮播图片',
'link.max' => '跳转链接最长 255 个字符',
'status.in' => '状态只能是 0 或 1',
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Client;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 宫格导航 创建/编辑 验证
*/
class NavFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'name' => 'required|string|max:50',
'image_id' => 'required|integer|min:1',
'link' => 'nullable|string|max:255',
'sort' => 'nullable|integer|min:0',
'status' => 'required|integer|in:0,1',
];
}
public function messages(): array
{
return [
'name.required' => '导航名称不能为空',
'name.max' => '导航名称最长 50 个字符',
'image_id.required' => '请上传导航图标',
'link.max' => '跳转链接最长 255 个字符',
'status.in' => '状态只能是 0 或 1',
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Client;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 促销推荐卡片 创建/编辑 验证
*/
class PromoFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'title' => 'nullable|string|max:50',
'sub_title' => 'nullable|string|max:100',
'image_id' => 'required|integer|min:1',
'link' => 'nullable|string|max:255',
'sort' => 'nullable|integer|min:0',
'status' => 'required|integer|in:0,1',
];
}
public function messages(): array
{
return [
'title.required' => '卡片标题不能为空',
'title.max' => '卡片标题最长 50 个字符',
'sub_title.max' => '副标题最长 100 个字符',
'image_id.required' => '请上传卡片图片',
'link.max' => '跳转链接最长 255 个字符',
'status.in' => '状态只能是 0 或 1',
];
}
}
@@ -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',
]; ];
} }
} }
@@ -2,9 +2,7 @@
namespace App\Http\Requests\Product; namespace App\Http\Requests\Product;
use Illuminate\Validation\Rules\Exists;
use Modules\Common\Http\Requests\BaseFormRequest; use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysFileModel;
/** /**
* 商品分类 创建/编辑 验证 * 商品分类 创建/编辑 验证
@@ -24,7 +22,6 @@ class ProductCategoryFormRequest extends BaseFormRequest
'name' => 'required|string|max:50', 'name' => 'required|string|max:50',
'parent_id' => 'required|integer|min:0', 'parent_id' => 'required|integer|min:0',
'sort' => 'nullable|integer', 'sort' => 'nullable|integer',
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
'status' => 'nullable|integer|in:0,1', 'status' => 'nullable|integer|in:0,1',
]; ];
} }
@@ -36,7 +33,6 @@ class ProductCategoryFormRequest extends BaseFormRequest
'name.max' => '分类名称最长 50 个字符', 'name.max' => '分类名称最长 50 个字符',
'parent_id.min' => '父级分类ID不正确', 'parent_id.min' => '父级分类ID不正确',
'status.in' => '状态值不正确', 'status.in' => '状态值不正确',
'icon_id.exists' => '请重新上传图片'
]; ];
} }
} }
@@ -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,28 +36,20 @@ 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
{ {
return function (Validator $validator): void { return function (Validator $validator): void {
$data = (array) $validator->getData(); $data = (array) $validator->getData();
foreach ((array) ($data['prices'] ?? []) as $index => $row) { $categoryId = (int) ($data['category_id'] ?? 0);
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED); if ($categoryId > 0 && ProductCategoryModel::where('parent_id', $categoryId)->exists()) {
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT $validator->errors()->add('category_id', '该分类下存在子分类,请选择末级分类');
&& (! array_key_exists('percent', (array) $row) || $row['percent'] === null || $row['percent'] === '')) {
$validator->errors()->add("prices.{$index}.percent", '按成本百分比计价时必须填写上浮百分点');
}
} }
}; };
} }
@@ -70,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',
]; ];
} }
} }
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量与售后金额/备注,金额由系统汇总不可修改;
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额;售后金额可正负,计入总金额)
*/
class PurchaseBillGenerateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'stores' => 'required|array|min:1',
'stores.*.store_id' => 'required|integer|distinct',
'stores.*.delivery_fee' => 'required|numeric|min:0',
'stores.*.box_num' => 'required|integer',
'stores.*.tray_num' => 'required|integer',
'stores.*.after_sale' => 'nullable|numeric',
'stores.*.remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'stores.required' => '请提交门店账单信息',
'stores.array' => '门店账单数据格式错误',
'stores.min' => '请提交门店账单信息',
'stores.*.store_id.required' => '门店ID不能为空',
'stores.*.store_id.integer' => '门店ID格式错误',
'stores.*.store_id.distinct' => '存在重复门店',
'stores.*.delivery_fee.required' => '配送费不能为空',
'stores.*.delivery_fee.numeric' => '配送费格式错误',
'stores.*.delivery_fee.min' => '配送费不能小于 0',
'stores.*.box_num.required' => '周转筐数量不能为空',
'stores.*.box_num.integer' => '周转筐数量必须为整数(正数=压筐,负数=回筐)',
'stores.*.tray_num.required' => '周转托盘数量不能为空',
'stores.*.tray_num.integer' => '周转托盘数量必须为整数(正数=压筐,负数=回筐)',
'stores.*.after_sale.numeric' => '售后金额格式错误(可正负)',
'stores.*.remark.max' => '备注最长 255 个字符',
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购单门店单品 新增/修改 验证(amount = 数量×单价 由后端重算)
*/
class PurchaseStoreItemRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if ($this->isUpdate()) {
return [
'quantity' => 'required|integer|min:0',
'price' => 'nullable|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
];
}
return [
'product_id' => 'required|integer|exists:product,id',
'quantity' => 'required|integer|min:1',
'weight' => 'nullable|numeric|min:0',
];
}
public function messages(): array
{
return [
'product_id.required' => '请选择商品',
'product_id.exists' => '商品不存在或已删除',
'quantity.required' => '采购数量不能为空',
'quantity.integer' => '采购数量必须为整数',
'quantity.min' => '采购数量不能小于 0',
'price.numeric' => '单价必须为数字',
'price.min' => '单价不能小于 0',
'weight.numeric' => '称重必须为数字',
'weight.min' => '称重不能小于 0',
];
}
}
@@ -1,38 +0,0 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 对账明细修改 验证(D4:订货量/称重/数量/金额/商品信息;diff 与头汇总由后端重算)
*/
class ReconItemUpdateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'product_name' => 'nullable|string|max:100',
'quantity' => 'nullable|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
'publish_amount' => 'nullable|numeric|min:0',
'actual_amount' => 'nullable|numeric|min:0',
];
}
public function messages(): array
{
return [
'quantity.numeric' => '订货量必须为数字',
'quantity.min' => '订货量不能小于 0',
'weight.numeric' => '称重必须为数字',
'weight.min' => '称重不能小于 0',
'publish_amount.numeric' => '公布金额必须为数字',
'publish_amount.min' => '公布金额不能小于 0',
'actual_amount.numeric' => '实际金额必须为数字',
'actual_amount.min' => '实际金额不能小于 0',
];
}
}
@@ -1,46 +0,0 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 财务对账单 创建/编辑 验证
*/
class ReconciliationFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
protected function prepareForValidation(): void
{
$this->merge([
'category_id' => (int) ($this->input('category_id') ?? 0),
'supplier_id' => (int) ($this->input('supplier_id') ?? 0),
]);
}
public function rules(): array
{
return [
'title' => 'required|string|max:100',
'period_start' => 'required|date_format:Y-m-d',
'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start',
'category_id' => 'required|integer|min:0',
'supplier_id' => 'required|integer|min:0',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'title.required' => '对账标题不能为空',
'title.max' => '对账标题最长 100 个字符',
'period_start.required' => '请选择对账周期开始日期',
'period_start.date_format' => '开始日期格式为 Y-m-d',
'period_end.required' => '请选择对账周期结束日期',
'period_end.date_format' => '结束日期格式为 Y-m-d',
'period_end.after_or_equal' => '结束日期不能早于开始日期',
];
}
}
-18
View File
@@ -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}$/',
];
}
}
+363
View File
@@ -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,
};
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\SystemUser\Models\SysUserModel;
/**
* 门店账单模型(采购单完成后按门店生成;商品金额由订单汇总快照,生成后不可修改)
*/
class BillModel extends Model
{
use HasFactory;
/** 支付状态:未支付 */
public const int STATUS_UNPAID = 0;
/** 支付状态:已支付 */
public const int STATUS_PAID = 1;
/** 支付状态中文名 */
public const array STATUS_NAMES = [
self::STATUS_UNPAID => '未支付',
self::STATUS_PAID => '已支付',
];
/** 支付进度(小程序端):待支付(可发起合并付款) */
public const int PAY_STATE_UNPAID = 0;
/** 支付进度:审核中(已提交合并付款凭证,待后台审核;审核拒绝后释放回待支付) */
public const int PAY_STATE_REVIEWING = 1;
/** 支付进度:已支付 */
public const int PAY_STATE_PAID = 2;
/** 支付进度中文名 */
public const array PAY_STATE_NAMES = [
self::PAY_STATE_UNPAID => '待支付',
self::PAY_STATE_REVIEWING => '审核中',
self::PAY_STATE_PAID => '已支付',
];
/**
* 支付进度推导:已支付 > 审核中(payment_id 锁定中)> 待支付
* (支付审核拒绝后 payment_id 释放为 0,回到待支付;线下收款直接置已支付)
*/
public function payState(): int
{
if ($this->status === self::STATUS_PAID) {
return self::PAY_STATE_PAID;
}
return $this->payment_id > 0 ? self::PAY_STATE_REVIEWING : self::PAY_STATE_UNPAID;
}
protected $table = 'bill';
protected $primaryKey = 'id';
protected $fillable = [
'bill_no',
'purchase_id',
'store_id',
'bill_date',
'product_amount',
'delivery_fee',
'box_num',
'tray_num',
'box_price',
'tray_price',
'added_amount',
'total_amount',
'status',
'payment_id',
'paid_at',
'pay_remark',
'paid_operator_id',
'operator_id',
'remark',
'after_sale',
];
protected $casts = [
'purchase_id' => 'integer',
'store_id' => 'integer',
'bill_date' => 'date:Y-m-d',
'product_amount' => 'decimal:2',
'delivery_fee' => 'decimal:2',
'box_num' => 'integer',
'tray_num' => 'integer',
'box_price' => 'decimal:2',
'tray_price' => 'decimal:2',
'added_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'after_sale' => 'decimal:2',
'status' => 'integer',
'payment_id' => 'integer',
'paid_at' => 'datetime:Y-m-d H:i:s',
'paid_operator_id' => 'integer',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 所属门店(含软删除门店,保证历史账单可见)
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed();
}
/**
* 关联采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 生成人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
/**
* 收款操作人(后台系统用户,线下收款登记)
*/
public function paidOperator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'paid_operator_id', 'id');
}
/**
* 关联支付记录(小程序合并付款)
*/
public function payment(): BelongsTo
{
return $this->belongsTo(PaymentModel::class, 'payment_id', 'id');
}
/**
* 本账单关联的门店订单
*/
public function orders(): HasMany
{
return $this->hasMany(StoreOrderModel::class, 'bill_id', 'id');
}
/**
* 本账单关联的门店订单明细
*/
public function items(): HasMany
{
return $this->hasMany(StoreOrderItemModel::class, 'bill_id', 'id');
}
}
+6 -6
View File
@@ -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');
} }
/** /**
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel;
/**
* 门店压回筐记录模型(周转筐/托盘跟账单走,生成账单时写入完整快照)
*
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额);数量为 0 不写记录
*/
class ContainerReturnModel extends Model
{
use HasFactory;
protected $table = 'container_return';
protected $primaryKey = 'id';
protected $fillable = [
'store_id',
'bill_id',
'box_num',
'tray_num',
'box_price',
'tray_price',
'amount',
'operator_id',
];
protected $casts = [
'store_id' => 'integer',
'bill_id' => 'integer',
'box_num' => 'integer',
'tray_num' => 'integer',
'box_price' => 'decimal:2',
'tray_price' => 'decimal:2',
'amount' => 'decimal:2',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 所属门店(含软删除门店,保证历史记录可见)
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed();
}
/**
* 关联账单
*/
public function bill(): BelongsTo
{
return $this->belongsTo(BillModel::class, 'bill_id', 'id');
}
/**
* 操作人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
}
+13 -4
View File
@@ -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);
} }
} }
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Modules\SystemTool\Models\SysFileModel;
/**
* 小程序首页轮播图
*/
class HomeBannerModel extends Model
{
/** 状态:停用 */
public const int STATUS_DISABLED = 0;
/** 状态:正常 */
public const int STATUS_NORMAL = 1;
protected $table = 'mini_home_banner';
protected $primaryKey = 'id';
protected $fillable = [
'title',
'image_id',
'link',
'sort',
'status',
];
protected $casts = [
'image_id' => 'integer',
'sort' => 'integer',
'status' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
protected $appends = ['image_url'];
protected $with = ['image'];
/**
* 关联图片
*/
public function image(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
}
/**
* 图片链接
*/
public function getImageUrlAttribute(): ?string
{
return $this->image?->preview_url;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Modules\SystemTool\Models\SysFileModel;
/**
* 小程序首页宫格导航
*/
class HomeNavModel extends Model
{
/** 状态:停用 */
public const int STATUS_DISABLED = 0;
/** 状态:正常 */
public const int STATUS_NORMAL = 1;
protected $table = 'mini_home_nav';
protected $primaryKey = 'id';
protected $fillable = [
'name',
'image_id',
'link',
'sort',
'status',
];
protected $casts = [
'image_id' => 'integer',
'sort' => 'integer',
'status' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
protected $appends = ['image_url'];
protected $with = ['image'];
/**
* 关联图标
*/
public function image(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
}
/**
* 图标链接
*/
public function getImageUrlAttribute(): ?string
{
return $this->image?->preview_url;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Modules\SystemTool\Models\SysFileModel;
/**
* 小程序首页促销推荐卡片
*/
class HomePromoModel extends Model
{
/** 状态:停用 */
public const int STATUS_DISABLED = 0;
/** 状态:正常 */
public const int STATUS_NORMAL = 1;
protected $table = 'mini_home_promo';
protected $primaryKey = 'id';
protected $fillable = [
'title',
'sub_title',
'image_id',
'link',
'sort',
'status',
];
protected $casts = [
'image_id' => 'integer',
'sort' => 'integer',
'status' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
protected $appends = ['image_url'];
protected $with = ['image'];
/**
* 关联图片
*/
public function image(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
}
/**
* 图片链接
*/
public function getImageUrlAttribute(): ?string
{
return $this->image?->preview_url;
}
}
+42
View File
@@ -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');
}
}
+8 -8
View File
@@ -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');
} }
} }
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemUser\Models\SysUserModel;
/**
* 支付记录模型(小程序选择门店账单合并付款)
*
* 支付类型 pay_type
* - 1 线下凭证支付:门店提交汇款凭证,后台审核通过后关联账单批量置已支付
* - 2 旺铺在线支付:调起微信/支付宝在线支付,网关回调(或主动查询)确认后自动结账
*/
class PaymentModel extends Model
{
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_ALIPAY = 2;
/** 支付方式:对公汇款(银行卡) */
public const int METHOD_BANK = 3;
/** 支付方式:旺铺在线支付(微信/支付宝小程序 JSAPI) */
public const int METHOD_WANGPU = 4;
/** 支付方式中文名 */
public const array METHOD_NAMES = [
self::METHOD_WECHAT => '微信支付',
self::METHOD_ALIPAY => '支付宝',
self::METHOD_BANK => '对公汇款',
self::METHOD_WANGPU => '旺铺支付',
];
/** 状态:待审核(线下凭证)/ 待支付(在线支付) */
public const int STATUS_PENDING = 0;
/** 状态:已通过(线下凭证)/ 支付成功(在线支付) */
public const int STATUS_APPROVED = 1;
/** 状态:已拒绝(线下凭证)/ 支付失败(在线支付) */
public const int STATUS_REJECTED = 2;
/** 状态中文名 */
public const array STATUS_NAMES = [
self::STATUS_PENDING => '待审核',
self::STATUS_APPROVED => '已通过',
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 $primaryKey = 'id';
protected $fillable = [
'payment_no',
'store_id',
'amount',
'pay_type',
'pay_method',
'voucher_ids',
'status',
'order_id',
'trade_no',
'openid',
'paid_at',
'pay_params',
'remark',
'audited_at',
'auditor_id',
'audit_remark',
];
protected $casts = [
'store_id' => 'integer',
'amount' => 'decimal:2',
'pay_type' => 'integer',
'pay_method' => 'integer',
'status' => 'integer',
'paid_at' => 'datetime:Y-m-d H:i:s',
'audited_at' => 'datetime:Y-m-d H:i:s',
'auditor_id' => 'integer',
'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(逗号分隔字符串 ↔ 数组)
*/
public function voucherIds(): Attribute
{
return Attribute::make(
get: fn ($value) => $value === '' || $value === null ? [] : explode(',', (string) $value),
set: fn ($value) => is_array($value) ? implode(',', $value) : $value,
);
}
/**
* 汇款凭证图片URL列表(保持提交顺序)
*
* @return string[]
*/
public function voucherUrls(): array
{
$ids = array_map('intval', $this->voucher_ids);
if ($ids === []) {
return [];
}
$urls = [];
foreach (SysFileModel::query()->whereIn('id', $ids)->get() as $file) {
$urls[$file->id] = $file->preview_url;
}
$result = [];
foreach ($ids as $id) {
if (isset($urls[$id])) {
$result[] = $urls[$id];
}
}
return $result;
}
/**
* 所属门店(含软删除门店,保证历史记录可见)
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed();
}
/**
* 本支付记录合并付款的账单
*/
public function bills(): HasMany
{
return $this->hasMany(BillModel::class, 'payment_id', 'id');
}
/**
* 审核人(后台系统用户)
*/
public function auditor(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'auditor_id', 'id');
}
}
+31 -26
View File
@@ -5,8 +5,6 @@ namespace App\Models;
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\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Modules\SystemTool\Models\SysFileModel;
/** /**
* 商品分类模型(蔬菜/水果/其他,多级分类自关联) * 商品分类模型(蔬菜/水果/其他,多级分类自关联)
@@ -25,40 +23,17 @@ class ProductCategoryModel extends Model
'parent_id', 'parent_id',
'name', 'name',
'sort', 'sort',
'status', 'status'
'icon_id'
]; ];
protected $casts = [ protected $casts = [
'parent_id' => 'integer', 'parent_id' => 'integer',
'sort' => 'integer', 'sort' => 'integer',
'status' => 'integer', 'status' => 'integer',
'icon_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s', 'created_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s', 'updated_at' => 'datetime:Y-m-d H:i:s',
]; ];
protected $appends = ['icon_url'];
protected $with = ['icon'];
/**
* 关联图标
*/
public function icon(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'icon_id');
}
// 图标链接
public function getIconUrlAttribute()
{
if($this->icon) {
return $this->icon->preview_url;
}
return null;
}
/** /**
* 父分类 * 父分类
*/ */
@@ -99,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;
}
/** /**
* 将扁平分类列表组装为树 * 将扁平分类列表组装为树
* *
+4 -11
View File
@@ -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');
}
} }
-108
View File
@@ -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),
);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel;
/**
* 采购单单品对账标记模型(商品明细「已对账」勾选,入库持久化;存在记录=已标记)
*/
class PurchaseItemCheckModel extends Model
{
protected $table = 'purchase_item_check';
protected $primaryKey = 'id';
protected $fillable = [
'purchase_id',
'product_id',
'operator_id',
];
protected $casts = [
'purchase_id' => 'integer',
'product_id' => 'integer',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 关联采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 标记人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
}
+11
View File
@@ -43,6 +43,9 @@ class PurchaseOrderModel extends Model
'estimate_amount' => 'decimal:2', 'estimate_amount' => 'decimal:2',
'actual_amount' => 'decimal:2', 'actual_amount' => 'decimal:2',
'operator_id' => 'integer', 'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
// 列表 withSum 聚合属性(应付商品金额;无账单时保持 null)
'bill_product_amount' => 'decimal:2',
]; ];
/** /**
@@ -68,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');
}
} }
-82
View File
@@ -1,82 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 对账明细模型(订货量/称重/数量/金额可修改,diff = publish actual
*/
class ReconciliationItemModel extends Model
{
/** 未对账 */
public const NOT_RECONCILED = 0;
/** 已对账 */
public const RECONCILED = 1;
protected $table = 'reconciliation_item';
protected $primaryKey = 'id';
protected $fillable = [
'recon_id',
'store_id',
'order_item_id',
'product_id',
'product_name',
'quantity',
'weight',
'publish_amount',
'actual_amount',
'diff_amount',
'is_reconciled',
'store_remark',
'sort',
];
protected $casts = [
'recon_id' => 'integer',
'store_id' => 'integer',
'order_item_id' => 'integer',
'product_id' => 'integer',
'quantity' => 'decimal:2',
'weight' => 'decimal:3',
'publish_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
'diff_amount' => 'decimal:2',
'is_reconciled' => 'integer',
'sort' => 'integer',
];
/**
* 所属对账单
*/
public function recon(): BelongsTo
{
return $this->belongsTo(ReconciliationModel::class, 'recon_id', 'id');
}
/**
* 所属门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 对账商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
}
/**
* 溯源订货明细
*/
public function orderItem(): BelongsTo
{
return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id');
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\SystemUser\Models\SysUserModel;
/**
* 财务对账模型(公布金额 vs 实际金额 vs 差额,按品类/供应商筛选)
*/
class ReconciliationModel extends Model
{
/** 状态:草稿 */
public const STATUS_DRAFT = 0;
/** 状态:对账中 */
public const STATUS_WORKING = 1;
/** 状态:已结算 */
public const STATUS_SETTLED = 2;
protected $table = 'reconciliation';
protected $primaryKey = 'id';
protected $fillable = [
'recon_no',
'title',
'period_start',
'period_end',
'category_id',
'supplier_id',
'publish_amount',
'actual_amount',
'diff_amount',
'status',
'operator_id',
'remark',
];
protected $casts = [
'period_start' => 'date:Y-m-d',
'period_end' => 'date:Y-m-d',
'category_id' => 'integer',
'supplier_id' => 'integer',
'publish_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
'diff_amount' => 'decimal:2',
'status' => 'integer',
'operator_id' => 'integer',
];
/**
* 制单人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
/**
* 对账明细
*/
public function items(): HasMany
{
return $this->hasMany(ReconciliationItemModel::class, 'recon_id', 'id')->orderBy('sort');
}
/**
* 结算表
*/
public function settlements(): HasMany
{
return $this->hasMany(SettlementModel::class, 'recon_id', 'id');
}
}
-74
View File
@@ -1,74 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel;
/**
* 结算表模型(对账结算按门店聚合生成,可导出存档)
*/
class SettlementModel extends Model
{
/** 状态:待结算 */
public const STATUS_PENDING = 0;
/** 状态:已结算 */
public const STATUS_SETTLED = 1;
protected $table = 'settlement';
protected $primaryKey = 'id';
protected $fillable = [
'settlement_no',
'recon_id',
'store_id',
'period_start',
'period_end',
'total_amount',
'actual_amount',
'diff_amount',
'status',
'file_path',
'operator_id',
'settled_at',
'remark',
];
protected $casts = [
'recon_id' => 'integer',
'store_id' => 'integer',
'period_start' => 'date:Y-m-d',
'period_end' => 'date:Y-m-d',
'total_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
'diff_amount' => 'decimal:2',
'status' => 'integer',
'operator_id' => 'integer',
'settled_at' => 'datetime',
];
/**
* 来源对账单
*/
public function recon(): BelongsTo
{
return $this->belongsTo(ReconciliationModel::class, 'recon_id', 'id');
}
/**
* 结算门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 制单人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
}
-78
View File
@@ -1,78 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 门店对账单明细模型(快照商品名/单价/数量/金额)
*/
class StatementItemModel extends Model
{
/** 未对账 */
public const NOT_RECONCILED = 0;
/** 已对账 */
public const RECONCILED = 1;
protected $table = 'statement_item';
protected $primaryKey = 'id';
protected $fillable = [
'statement_id',
'order_id',
'order_item_id',
'product_id',
'product_name',
'price',
'quantity',
'weight',
'amount',
'is_reconciled',
'store_remark',
];
protected $casts = [
'statement_id' => 'integer',
'order_id' => 'integer',
'order_item_id' => 'integer',
'product_id' => 'integer',
'price' => 'decimal:2',
'quantity' => 'decimal:2',
'weight' => 'decimal:3',
'amount' => 'decimal:2',
'is_reconciled' => 'integer',
];
/**
* 所属对账单
*/
public function statement(): BelongsTo
{
return $this->belongsTo(StatementModel::class, 'statement_id', 'id');
}
/**
* 来源订单
*/
public function order(): BelongsTo
{
return $this->belongsTo(StoreOrderModel::class, 'order_id', 'id');
}
/**
* 来源订单明细
*/
public function orderItem(): BelongsTo
{
return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id');
}
/**
* 对账商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
}
}
-65
View File
@@ -1,65 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 门店对账单模型(门店自助生成,快照回款周期:settlement_date = period_end + payment_cycle_days
*/
class StatementModel extends Model
{
/** 状态:待对账 */
public const STATUS_PENDING = 0;
/** 状态:已对账 */
public const STATUS_RECONCILED = 1;
/** 状态:已结算 */
public const STATUS_SETTLED = 2;
protected $table = 'statement';
protected $primaryKey = 'id';
protected $fillable = [
'statement_no',
'store_id',
'period_start',
'period_end',
'total_amount',
'payment_cycle_days',
'settlement_date',
'status',
'reconciled_at',
'settled_at',
'remark',
];
protected $casts = [
'store_id' => 'integer',
'period_start' => 'date:Y-m-d',
'period_end' => 'date:Y-m-d',
'total_amount' => 'decimal:2',
'payment_cycle_days' => 'integer',
'settlement_date' => 'date:Y-m-d',
'status' => 'integer',
'reconciled_at' => 'datetime',
'settled_at' => 'datetime',
];
/**
* 所属门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 对账单明细
*/
public function items(): HasMany
{
return $this->hasMany(StatementItemModel::class, 'statement_id', 'id');
}
}
+34 -10
View File
@@ -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,19 +28,33 @@ class StoreModel extends Model
protected $fillable = [ protected $fillable = [
'name', 'name',
'code', 'code',
'username',
'password',
'avatar',
'last_login_at',
'level_id', 'level_id',
'contact', 'contact',
'phone', 'phone',
'address', 'address',
'payment_cycle_days', 'payment_cycle_days',
'openid',
'mp_openid',
'status', 'status',
'remark', 'remark',
]; ];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [ protected $casts = [
'level_id' => 'integer', 'level_id' => 'integer',
'payment_cycle_days' => 'integer', 'payment_cycle_days' => 'integer',
'total_purchase_amount' => 'decimal:2',
'status' => 'integer', 'status' => 'integer',
'last_login_at' => 'datetime:Y-m-d H:i:s',
'created_at' => 'datetime:Y-m-d H:i:s',
]; ];
/** /**
@@ -58,18 +74,26 @@ class StoreModel extends Model
} }
/** /**
* 绑定本门店的小程序用户 * 门店账单(采购单完成后按门店生成)
*/ */
public function users(): HasMany public function bills(): HasMany
{ {
return $this->hasMany(UserModel::class, 'store_id', 'id'); return $this->hasMany(BillModel::class, 'store_id', 'id');
} }
/** /**
* 门店对账单 * 门店购物车
*/ */
public function statements(): HasMany public function carts(): HasMany
{ {
return $this->hasMany(StatementModel::class, 'store_id', 'id'); return $this->hasMany(CartModel::class, 'store_id', 'id');
}
/**
* 门店通知
*/
public function notices(): HasMany
{
return $this->hasMany(NoticeModel::class, 'store_id', 'id');
} }
} }
+3
View File
@@ -21,6 +21,7 @@ class StoreOrderItemModel extends Model
protected $fillable = [ protected $fillable = [
'order_id', 'order_id',
'purchase_id', 'purchase_id',
'bill_id',
'store_id', 'store_id',
'product_id', 'product_id',
'category_id', 'category_id',
@@ -29,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',
@@ -42,6 +44,7 @@ class StoreOrderItemModel extends Model
protected $casts = [ protected $casts = [
'order_id' => 'integer', 'order_id' => 'integer',
'purchase_id' => 'integer', 'purchase_id' => 'integer',
'bill_id' => 'integer',
'store_id' => 'integer', 'store_id' => 'integer',
'product_id' => 'integer', 'product_id' => 'integer',
'category_id' => 'integer', 'category_id' => 'integer',
+18 -9
View File
@@ -48,27 +48,20 @@ class StoreOrderModel extends Model
'total_quantity', 'total_quantity',
'total_weight', 'total_weight',
'total_amount', 'total_amount',
'product_amount',
'added_amount',
'box_num',
'tray_num',
'status', 'status',
'remark', 'remark',
'purchase_id', 'purchase_id',
'bill_id',
]; ];
protected $casts = [ protected $casts = [
'store_id' => 'integer', 'store_id' => 'integer',
'purchase_id' => 'integer', 'purchase_id' => 'integer',
'statement_id' => 'integer', 'bill_id' => 'integer',
'order_date' => 'date:Y-m-d', 'order_date' => 'date:Y-m-d',
'total_quantity' => 'integer', 'total_quantity' => 'integer',
'total_weight' => 'decimal:3', 'total_weight' => 'decimal:3',
'total_amount' => 'decimal:2', 'total_amount' => 'decimal:2',
'product_amount' => 'decimal:2',
'added_amount' => 'decimal:2',
'box_num' => 'integer',
'tray_num' => 'integer',
'status' => 'integer', 'status' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s', 'created_at' => 'datetime:Y-m-d H:i:s',
]; ];
@@ -88,4 +81,20 @@ class StoreOrderModel extends Model
{ {
return $this->hasMany(StoreOrderItemModel::class, 'order_id', 'id'); return $this->hasMany(StoreOrderItemModel::class, 'order_id', 'id');
} }
/**
* 采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 关联账单(采购单完成后按门店生成)
*/
public function bill(): BelongsTo
{
return $this->belongsTo(BillModel::class, 'bill_id', 'id');
}
} }
-8
View File
@@ -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');
}
} }
-68
View File
@@ -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');
}
}
-4
View File
@@ -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);
} }
/** /**
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services;
use App\Models\BillModel;
use App\Models\ProductModel;
use App\Models\StoreOrderItemModel;
use Illuminate\Support\Collection;
/**
* 账单详情数据组装(后台门店账单页与小程序账单详情共用)
*/
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, image: string}>
*/
public function mergedItems(BillModel $bill): array
{
$items = StoreOrderItemModel::query()
->where('bill_id', $bill->id)
->orderBy('id')
->get();
return $this->aggregateItems($items);
}
/**
* 合并后的商品明细(跨账单口径):按商品聚合多张账单的全部订单明细
* 用于账单合并导出,单价同为加权平均口径
*
* @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, image: string}>
*/
public function mergedItemsOfBills(array $billIds): array
{
$items = StoreOrderItemModel::query()
->whereIn('bill_id', $billIds)
->orderBy('id')
->get();
return $this->aggregateItems($items);
}
/**
* 按商品聚合订单明细:数量累加、重量/金额 bc 累加、单价加权平均,
* 首图取明细快照 image_ids 批量解析,按「分类sort → 商品sort」排序
*
* @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, image: string}>
*/
private function aggregateItems(Collection $items): array
{
// 排序键:分类 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;
$weight = '0';
$amount = '0';
foreach ($group as $item) {
$quantity += (int) $item->quantity;
$weight = bcadd($weight, (string) $item->weight, 3);
$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,
'weight' => $weight,
'amount' => $amount,
'spec' => $product->spec,
'price_unit' => $product->price_unit,
'image_ids' => (array) $first->image_ids,
'category_sort' => (int) ($product->category->sort ?? 9999),
'product_sort' => (int) ($product->sort ?? 9999),
];
}
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']]);
// 首图批量解析(写入 image、移除 image_ids
app(ItemImageResolver::class)->resolve($rows);
return array_map(static function (array $row): array {
unset($row['category_sort'], $row['product_sort']);
return $row;
}, $rows);
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\ContainerReturnModel;
use App\Models\CustomerLevelModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 门店账单生成(采购单完成后,按门店各生成一张账单)
*
* 流程(事务内):
* 1. 锁定采购单全部有效订单,按门店分组,校验提交门店完整覆盖
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
* 可正负:正数=加收,负数=售后减免)
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
*/
readonly class BillGenerateService
{
public function __construct(private BillNumberService $billNumberService)
{
}
/**
* @param PurchaseOrderModel $purchase 已完成采购单
* @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)
* @return BillModel[] 生成的账单列表
* @throws Throwable
*/
public function generate(PurchaseOrderModel $purchase, array $stores, int $operatorId): array
{
return DB::transaction(function () use ($purchase, $stores, $operatorId) {
// 1. 行锁采购单全部有效订单(并发防护),按门店分组
$orders = StoreOrderModel::query()
->where('purchase_id', $purchase->id)
->whereNull('deleted_at')
->lockForUpdate()
->get();
if ($orders->isEmpty()) {
throw new RepositoryException('采购单下无门店订单,无法生成账单');
}
$ordersByStore = $orders->groupBy('store_id');
// 提交的门店必须完整覆盖采购单门店,避免漏门店造成订单未入账
$submitted = [];
foreach ($stores as $row) {
$submitted[(int) $row['store_id']] = $row;
}
$missing = array_diff(array_map('intval', $ordersByStore->keys()->all()), array_keys($submitted));
if ($missing !== []) {
throw new RepositoryException('提交不完整,缺少门店账单信息:' . implode('、', $missing));
}
if (array_diff(array_keys($submitted), array_map('intval', $ordersByStore->keys()->all())) !== []) {
throw new RepositoryException('包含不属于该采购单的门店');
}
// 2. 防重复生成:任一门店已出账则整批拒绝
$billedStoreIds = BillModel::query()
->where('purchase_id', $purchase->id)
->pluck('store_id')
->map(static fn ($id) => (int) $id)
->all();
if ($billedStoreIds !== []) {
$names = StoreModel::withTrashed()
->whereIn('id', $billedStoreIds)
->pluck('name')
->implode('、');
throw new RepositoryException('以下门店已生成账单,不允许重复生成:' . $names);
}
// 3. 逐门店生成账单并关联订单
$boxPrice = (string) site_config('services.box_amount', 0);
$trayPrice = (string) site_config('services.tray_amount', 0);
$billDate = now()->toDateString();
// 各门店客户等级上浮比例(售后金额折算用;无等级按 0 不上浮)
$levelPercents = StoreModel::withTrashed()
->with('level:id,percent')
->whereIn('id', $ordersByStore->keys())
->get(['id', 'level_id'])
->mapWithKeys(static fn (StoreModel $store) => [
$store->id => (string) ($store->level?->percent ?? '0'),
]);
$bills = [];
foreach ($ordersByStore as $storeId => $storeOrders) {
$row = $submitted[(int) $storeId];
$productAmount = $storeOrders->reduce(
static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
'0'
);
$deliveryFee = bcadd((string) $row['delivery_fee'], '0', 2);
$addedAmount = bcadd(
bcmul((string) (int) $row['box_num'], $boxPrice, 2),
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
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_no' => $this->billNumberService->make('ZD'),
'purchase_id' => $purchase->id,
'store_id' => (int) $storeId,
'bill_date' => $billDate,
'product_amount' => $productAmount,
'delivery_fee' => $deliveryFee,
'box_num' => (int) $row['box_num'],
'tray_num' => (int) $row['tray_num'],
'box_price' => bcadd($boxPrice, '0', 2),
'tray_price' => bcadd($trayPrice, '0', 2),
'added_amount' => $addedAmount,
'total_amount' => $totalAmount,
'operator_id' => $operatorId,
'after_sale' => $afterSale,
'remark' => (string) ($row['remark'] ?? ''),
]);
// 关联该门店在采购单中的全部订单与订单明细到账单,订单转入「已完成」
StoreOrderModel::query()
->whereIn('id', $storeOrders->pluck('id'))
->update([
'bill_id' => $bill->id,
'status' => StoreOrderModel::STATUS_COMPLETED,
]);
StoreOrderItemModel::query()
->whereIn('order_id', $storeOrders->pluck('id'))
->update(['bill_id' => $bill->id]);
// 压回筐记录:数量为 0 不写记录;正数=压筐附加金额,负数=回筐抵扣金额
if ((int) $row['box_num'] !== 0 || (int) $row['tray_num'] !== 0) {
ContainerReturnModel::create([
'store_id' => (int) $storeId,
'bill_id' => $bill->id,
'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,
]);
}
$bills[] = $bill;
}
return $bills;
});
}
}
+3 -4
View File
@@ -24,15 +24,14 @@ class BillNumberService
private const NUMBER_SOURCES = [ private const NUMBER_SOURCES = [
'PO' => ['purchase_order', 'purchase_no'], 'PO' => ['purchase_order', 'purchase_no'],
'SO' => ['store_order', 'order_no'], 'SO' => ['store_order', 'order_no'],
'RC' => ['reconciliation', 'recon_no'], 'ZD' => ['bill', 'bill_no'],
'ST' => ['statement', 'statement_no'], 'ZF' => ['payment', 'payment_no'],
'JS' => ['settlement', 'settlement_no'],
]; ];
/** /**
* 生成业务单号 * 生成业务单号
* *
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算 * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / ZD 账单 / ZF 支付
* @return string 如 PO202607230001 * @return string 如 PO202607230001
*/ */
public function make(string $prefix): string public function make(string $prefix): string
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\CartModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\StoreModel;
/**
* 购物车共享服务:商品列表/详情附加购物车数量、悬浮球汇总(种数/总数量/总金额)
*
* 汇总口径(与 Mini/CartController::index 一致,勿偏离):
* - total_count = 购物车全部行数(含已下架等不可购项)
* - total_quantity / total_amount = 仅统计可购项(商品在上架且门店已设等级),
* 金额 = Σ 数量 × 等级上浮价(CustomerLevelModel::calcLevelPrice
*/
class CartService
{
/**
* 门店购物车行映射:product_id => ['id' => 购物车行ID, 'quantity' => 数量字符串]
* (商品列表/详情附加 cart_id/cart_quantity 用;行ID供直接加减/删除操作)
*
* @return array<int, array{id: int, quantity: string}>
*/
public function cartRowMap(int $storeId): array
{
return CartModel::query()
->where('store_id', $storeId)
->get(['id', 'product_id', 'quantity'])
->keyBy('product_id')
->map(static fn (CartModel $row): array => [
'id' => (int) $row->id,
'quantity' => (string) $row->quantity,
])
->all();
}
/**
* 购物车悬浮球汇总:种数/总数量/总金额(未登录或空购物车返回零值结构)
*
* @return array{total_count: int, total_quantity: string, total_amount: string}
*/
public function summary(?StoreModel $store): array
{
$empty = ['total_count' => 0, 'total_quantity' => '0.00', 'total_amount' => '0.00'];
if ($store === null) {
return $empty;
}
$rows = CartModel::query()
->where('store_id', $store->id)
->get(['id', 'product_id', 'quantity']);
if ($rows->isEmpty()) {
return $empty;
}
$products = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->whereIn('id', $rows->pluck('product_id')->all())
->get(['id', 'cost_price'])
->keyBy('id');
$level = $store->level_id > 0 ? $store->level : null;
$totalQuantity = '0.00';
$totalAmount = '0.00';
foreach ($rows as $row) {
$product = $products->get($row->product_id);
if ($product === null || $level === null) {
continue;
}
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
$totalQuantity = bcadd($totalQuantity, (string) $row->quantity, 2);
$totalAmount = bcadd($totalAmount, bcmul($price, (string) $row->quantity, 2), 2);
}
return [
'total_count' => $rows->count(),
'total_quantity' => $totalQuantity,
'total_amount' => $totalAmount,
];
}
}
-111
View File
@@ -1,111 +0,0 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport;
use App\Exports\SettlementExport;
use App\Exports\StatementExport;
use App\Models\PurchaseOrderModel;
use App\Models\SettlementModel;
use App\Models\StatementModel;
use Barryvdh\DomPDF\Facade\Pdf;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\Response;
/**
* 导出统一入口:按业务类型 + format 分发到 app/Exports 导出类 / PDF 模板
*
* - Excel 分支:Excel::download(new XxxExport(...))
* - PDF 分支:Pdf::loadView('exports.xxx', ...)->setPaper('a4')->download(...),模板统一 font-family: SimHei
* - 文件名规范:{单号}_{业务名}.{ext},中文文件名由响应自动做 RFC 5987 编码
*/
class ExportService
{
/** 导出格式:Excel */
public const FORMAT_XLSX = 'xlsx';
/** 导出格式:PDF */
public const FORMAT_PDF = 'pdf';
/**
* 导出下载
*
* @param string $business 业务类型:purchase 采购单 / statement 门店对账单 / settlement 结算表
* @param mixed $subject 业务主体(如 PurchaseOrderModel / StatementModel / SettlementModel 实例)
* @param string $format 导出格式 xlsx|pdf,默认 xlsx,非法值报错
* @param string|null $type 业务子类型(purchase 专用:all 全品类 / category 仅蔬果分类)
* @return Response 文件流响应(blob
*/
public function download(
string $business,
mixed $subject,
string $format = self::FORMAT_XLSX,
?string $type = null,
): Response {
if (! in_array($format, [self::FORMAT_XLSX, self::FORMAT_PDF], true)) {
throw new RepositoryException('导出格式参数不正确(仅支持 xlsx / pdf');
}
return match ($business) {
'purchase' => $this->exportPurchase($subject, $format, $type),
'statement' => $this->exportStatement($subject, $format),
'settlement' => $this->exportSettlement($subject, $format),
default => throw new RepositoryException('不支持的导出业务类型:' . $business),
};
}
/**
* C2/C3 采购单导出
*/
private function exportPurchase(PurchaseOrderModel $purchase, string $format, ?string $type): Response
{
$type = in_array($type, ['all', 'category'], true) ? $type : 'all';
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $purchase->purchase_no . '_采购单.' . $extension;
$export = new PurchaseOrderExport($purchase, $type);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.purchase', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
/**
* 门店对账单导出
*/
private function exportStatement(StatementModel $statement, string $format): Response
{
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $statement->statement_no . '_对账单.' . $extension;
$export = new StatementExport($statement);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.statement', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
/**
* D10 结算表导出
*/
private function exportSettlement(SettlementModel $settlement, string $format): Response
{
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $settlement->settlement_no . '_结算表.' . $extension;
$export = new SettlementExport($settlement);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.settlement', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
}

Some files were not shown because too many files have changed in this diff Show More