Compare commits
69 Commits
0211e5e98d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3005646179 | |||
| edf5e42c38 | |||
| 58c733baa2 | |||
| 7de51391e4 | |||
| 379aeac2b3 | |||
| a636bd31b5 | |||
| b784210012 | |||
| 51ccaaf11a | |||
| 7a895c143e | |||
| f9eb6f7d32 | |||
| f8e81cb830 | |||
| 52f396c12e | |||
| b1bb40a088 | |||
| 12cbed411b | |||
| 364471a086 | |||
| 5c7a5829af | |||
| 17c1281073 | |||
| 968054b9ff | |||
| c33247f214 | |||
| 1e25459676 | |||
| cf1d3a6229 | |||
| 52e339cdd3 | |||
| c986c86ed5 | |||
| 1370926780 | |||
| d190bdaade | |||
| 95ea0714ad | |||
| 9c59f3bc99 | |||
| 6c809d893b | |||
| f8cc40a609 | |||
| d0ce897a0f | |||
| c3ec416a77 | |||
| c35dac0055 | |||
| 65eb8ef594 | |||
| f3e190f460 | |||
| 52dc6efadf | |||
| 513cc568a9 | |||
| 5d58075644 | |||
| 01e67e02b5 | |||
| e5f849ef4d | |||
| 1dfa084508 | |||
| 4fe4e985f6 | |||
| 22237a47e7 | |||
| cd7007bc49 | |||
| 52ab195432 | |||
| c1d490134e | |||
| 6d9f02d831 | |||
| 3dbb9205a4 | |||
| a06c45dc49 | |||
| 2eb27127c9 | |||
| 7517d4a023 | |||
| 8368c51e3f | |||
| cd35213ae6 | |||
| 7c3f6147ac | |||
| 4bdaf2a219 | |||
| 228212f8e3 | |||
| 398bb98356 | |||
| 9a0999742f | |||
| 969eabcd11 | |||
| f0927e57e9 | |||
| 629d28a369 | |||
| 8b3cef5af0 | |||
| 889f987f17 | |||
| bf8ac68391 | |||
| e35c951a59 | |||
| 61a70319a0 | |||
| 099e92b1ff | |||
| 79e3ed4a8a | |||
| 91ea91784d | |||
| 6c794d0732 |
@@ -46,3 +46,36 @@ MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM_ADDRESS=
|
||||
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
@@ -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 一级分类ID(0=全部)
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\ContainerReturnModel;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 压回筐记录汇总导出:行=日期(区间内逐日),列=门店,
|
||||
* 单元格=当日该门店抵扣(附加)金额合计(同日多条记录合并),无记录填 0;
|
||||
* 末列为行合计,末行为列合计(右下角为总计)
|
||||
*
|
||||
* WithStrictNullComparison:PhpSpreadsheet fromArray 默认松散比较(0 == null 为 true),
|
||||
* 数值 0 的单元格会被跳过写成空白,必须启用严格比较才能把 0 填进表格
|
||||
*/
|
||||
class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNullComparison
|
||||
{
|
||||
/** 门店列填充色(浅蓝) */
|
||||
private const string STORE_FILL = 'FFDDEBF7';
|
||||
|
||||
/** 合计列填充色(浅红) */
|
||||
private const string AMOUNT_FILL = 'FFFFCCCC';
|
||||
|
||||
/** 金额格式:¥ + 两位小数 */
|
||||
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
|
||||
|
||||
private ?Collection $rows = null;
|
||||
|
||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/total) */
|
||||
private array $specialRows = [];
|
||||
|
||||
/** @var array<int, array{stores: array<int, float>, total: float}> 日期行索引 => 填色判断数据 */
|
||||
private array $rowData = [];
|
||||
|
||||
/** @var array{stores: array<int, float>, total: float} 合计行填色判断数据 */
|
||||
private array $summaryTotals = ['stores' => [], 'total' => 0.0];
|
||||
|
||||
/** 列头所在行索引 */
|
||||
private int $headerRow = 1;
|
||||
|
||||
/** 数据末行索引 */
|
||||
private int $lastRow = 1;
|
||||
|
||||
/**
|
||||
* @param Collection<int, ContainerReturnModel> $records 区间内压回筐记录
|
||||
* @param array<int, string> $storeNames 门店ID => 名称(导出列,升序)
|
||||
* @param string $startDate 开始日期 Y-m-d
|
||||
* @param string $endDate 结束日期 Y-m-d
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Collection $records,
|
||||
private readonly array $storeNames,
|
||||
private readonly string $startDate,
|
||||
private readonly string $endDate,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出行:标题/范围/列头/日期矩阵/合计全部手工构建(行位置不固定,不用 WithHeadings)
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
if ($this->rows !== null) {
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
// 同日期同门店多条记录合并:金额代数和(正=压筐附加,负=回筐抵扣)
|
||||
$amounts = [];
|
||||
foreach ($this->records as $record) {
|
||||
$date = $record->created_at->format('Y-m-d');
|
||||
$amounts[$date][(int) $record->store_id] = bcadd(
|
||||
$amounts[$date][(int) $record->store_id] ?? '0',
|
||||
(string) $record->amount,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$rowIndex = 0;
|
||||
|
||||
// 标题行
|
||||
$rows[] = ['压回筐记录汇总(' . $this->startDate . ' ~ ' . $this->endDate . ')'];
|
||||
$this->specialRows[++$rowIndex] = 'title';
|
||||
|
||||
// 范围行:门店 / 导出时间
|
||||
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
|
||||
$this->specialRows[++$rowIndex] = 'scope';
|
||||
|
||||
// 空行
|
||||
$rows[] = [''];
|
||||
$rowIndex++;
|
||||
|
||||
// 列头:日期 + 门店列 + 行合计
|
||||
$rows[] = array_merge(['日期'], array_values($this->storeNames), ['合计']);
|
||||
$this->specialRows[++$rowIndex] = 'header';
|
||||
$this->headerRow = $rowIndex;
|
||||
|
||||
// 日期矩阵:区间内逐日一行,当天门店无记录填 0
|
||||
$storeIds = array_map(intval(...), array_keys($this->storeNames));
|
||||
$columnTotals = array_fill_keys($storeIds, '0');
|
||||
$grandTotal = '0';
|
||||
foreach (CarbonPeriod::create($this->startDate, $this->endDate) as $date) {
|
||||
$dateKey = $date->format('Y-m-d');
|
||||
$row = [$dateKey];
|
||||
$rowTotal = '0';
|
||||
$storeValues = [];
|
||||
foreach ($storeIds as $storeId) {
|
||||
$amount = $amounts[$dateKey][$storeId] ?? '0.00';
|
||||
$row[] = (float) $amount;
|
||||
$storeValues[$storeId] = (float) $amount;
|
||||
$rowTotal = bcadd($rowTotal, $amount, 2);
|
||||
$columnTotals[$storeId] = bcadd($columnTotals[$storeId], $amount, 2);
|
||||
}
|
||||
$grandTotal = bcadd($grandTotal, $rowTotal, 2);
|
||||
$row[] = (float) $rowTotal;
|
||||
$rows[] = $row;
|
||||
$this->rowData[++$rowIndex] = ['stores' => $storeValues, 'total' => (float) $rowTotal];
|
||||
}
|
||||
|
||||
// 合计行:各门店列合计 + 总计
|
||||
$totalRow = ['合计'];
|
||||
foreach ($storeIds as $storeId) {
|
||||
$totalRow[] = (float) $columnTotals[$storeId];
|
||||
}
|
||||
$totalRow[] = (float) $grandTotal;
|
||||
$rows[] = $totalRow;
|
||||
$this->specialRows[++$rowIndex] = 'total';
|
||||
$this->lastRow = $rowIndex;
|
||||
$this->summaryTotals = [
|
||||
'stores' => array_map(floatval(...), $columnTotals),
|
||||
'total' => (float) $grandTotal,
|
||||
];
|
||||
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题/列头/合计行加粗;全表居中 + 全边框;金额区货币格式;
|
||||
* 门店列(浅蓝)/合计列(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头行
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$this->collection();
|
||||
|
||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||
$sheet->getColumnDimension('A')->setWidth(12);
|
||||
$columnCount = count($this->storeNames) + 2;
|
||||
for ($column = 2; $column <= $columnCount; $column++) {
|
||||
$sheet->getColumnDimensionByColumn($column)->setWidth(12);
|
||||
}
|
||||
|
||||
$storeIds = array_map(intval(...), array_keys($this->storeNames));
|
||||
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
|
||||
|
||||
// 全部单元格水平/垂直居中
|
||||
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
|
||||
->getAlignment()
|
||||
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||
->setVertical(Alignment::VERTICAL_CENTER);
|
||||
|
||||
// 表格区域(列头 → 合计行)添加所有边框
|
||||
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
|
||||
->getBorders()
|
||||
->getAllBorders()
|
||||
->setBorderStyle(Border::BORDER_THIN);
|
||||
|
||||
// 金额区(门店列 + 合计列)货币格式:¥ + 两位小数
|
||||
$sheet->getStyle('B' . ($this->headerRow + 1) . ':' . $lastColumn . $this->lastRow)
|
||||
->getNumberFormat()
|
||||
->setFormatCode(self::CURRENCY_FORMAT);
|
||||
|
||||
// 门店列(B 起):非 0 浅蓝,0 无背景,列头固定浅蓝
|
||||
foreach ($storeIds as $i => $storeId) {
|
||||
$column = Coordinate::stringFromColumnIndex(2 + $i);
|
||||
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
|
||||
foreach ($this->rowData as $row => $data) {
|
||||
if (($data['stores'][$storeId] ?? 0.0) !== 0.0) {
|
||||
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
|
||||
}
|
||||
}
|
||||
if (($this->summaryTotals['stores'][$storeId] ?? 0.0) !== 0.0) {
|
||||
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
|
||||
}
|
||||
}
|
||||
|
||||
// 合计列(末列):非 0 浅红,0 无背景,列头固定浅红
|
||||
$this->fillCell($sheet, $lastColumn . $this->headerRow, self::AMOUNT_FILL);
|
||||
foreach ($this->rowData as $row => $data) {
|
||||
if ($data['total'] !== 0.0) {
|
||||
$this->fillCell($sheet, $lastColumn . $row, self::AMOUNT_FILL);
|
||||
}
|
||||
}
|
||||
if ($this->summaryTotals['total'] !== 0.0) {
|
||||
$this->fillCell($sheet, $lastColumn . $this->lastRow, self::AMOUNT_FILL);
|
||||
}
|
||||
|
||||
$styles = [];
|
||||
foreach ($this->specialRows as $row => $type) {
|
||||
$style = match ($type) {
|
||||
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||
'header', 'total' => ['font' => ['bold' => true]],
|
||||
default => [],
|
||||
};
|
||||
if ($style !== []) {
|
||||
$styles[$row] = $style;
|
||||
}
|
||||
}
|
||||
|
||||
return $styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元格纯色填充
|
||||
*/
|
||||
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||
{
|
||||
$sheet->getStyle($coordinate)
|
||||
->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()
|
||||
->setARGB($argb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 商品档案导出:列格式与导入模板完全一致(首行即列头,导出文件修改后可直接重新导入)。
|
||||
* 分类列输出「父分类/子分类」完整路径;状态列输出中文。
|
||||
* template 模式仅输出列头 + 一行示例数据(首次导入前下载参考)。
|
||||
*/
|
||||
class ProductExport implements FromCollection, WithStrictNullComparison, WithStyles
|
||||
{
|
||||
/** 列头(与 ProductImport 的解析顺序一一对应,改动需同步) */
|
||||
public const array HEADERS = ['品名', '分类', '供应商', '市场', '规格/包规', '单位', '成本价', '排序', '库存', '保质期', '状态', '备注'];
|
||||
|
||||
private ?Collection $rows = null;
|
||||
|
||||
/**
|
||||
* @param int $categoryId 末级分类筛选(0=全部商品)
|
||||
* @param bool $template 仅输出列头 + 示例行(导入模板)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $categoryId = 0,
|
||||
private readonly bool $template = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 行结构:第 1 行列头,其后每行一件商品
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
if ($this->rows !== null) {
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
$rows = [self::HEADERS];
|
||||
|
||||
if ($this->template) {
|
||||
$rows[] = ['西红柿', '蔬菜/茄果类', '示例供应商', '新发地', '10斤/箱', '斤', 2.5, 0, 100, 3, '上架', '示例行,导入前请删除'];
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
|
||||
$products = ProductModel::query()
|
||||
->when($this->categoryId > 0, fn ($query) => $query->where('category_id', $this->categoryId))
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->makeVisible('cost_price');
|
||||
|
||||
$categoryPaths = $this->categoryPaths($products->pluck('category_id')->unique()->all());
|
||||
$supplierNames = SupplierModel::withTrashed()->pluck('name', 'id');
|
||||
|
||||
foreach ($products as $product) {
|
||||
$rows[] = [
|
||||
$product->name,
|
||||
$categoryPaths[(int) $product->category_id] ?? '',
|
||||
$supplierNames[(int) $product->supplier_id] ?? '',
|
||||
$product->market,
|
||||
$product->spec,
|
||||
$product->unit,
|
||||
(float) $product->cost_price,
|
||||
(int) $product->sort,
|
||||
(int) $product->stock,
|
||||
(int) $product->shelf_life,
|
||||
(int) $product->status === ProductModel::STATUS_ON ? '上架' : '下架',
|
||||
$product->remark,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列头加粗、冻结首行、设置列宽
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$this->collection();
|
||||
|
||||
$sheet->freezePane('A2');
|
||||
$widths = [20, 16, 14, 12, 14, 8, 10, 8, 8, 8, 8, 24];
|
||||
foreach ($widths as $index => $width) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类ID => 「父分类/子分类」完整路径(沿 parent_id 上溯拼接)
|
||||
*
|
||||
* @param array<int, int> $categoryIds
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function categoryPaths(array $categoryIds): array
|
||||
{
|
||||
$categories = ProductCategoryModel::all()->keyBy('id');
|
||||
$paths = [];
|
||||
foreach ($categoryIds as $categoryId) {
|
||||
$names = [];
|
||||
$cursor = (int) $categoryId;
|
||||
$guard = 0;
|
||||
while ($cursor > 0 && $guard++ < 20) {
|
||||
$category = $categories->get($cursor);
|
||||
if ($category === null) {
|
||||
break;
|
||||
}
|
||||
array_unshift($names, $category->name);
|
||||
$cursor = (int) $category->parent_id;
|
||||
}
|
||||
$paths[(int) $categoryId] = implode('/', $names);
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
+294
-109
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
@@ -10,47 +11,74 @@ use App\Models\StoreOrderItemModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 采购单导出(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 => 名称(导出列) */
|
||||
private array $storeNames = [];
|
||||
|
||||
private ?Collection $items = null;
|
||||
|
||||
/**
|
||||
* @param PurchaseOrderModel $purchase 采购单
|
||||
* @param string $type all 全品类 / category 仅蔬果分类
|
||||
* @param int $supplierId 供应商筛选(0=全部)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly PurchaseOrderModel $purchase,
|
||||
private readonly string $type = 'all',
|
||||
private readonly int $supplierId = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出行(商品聚合行,按 分类sort → 商品sort 排序;蔬果分类时按顶级分类名过滤)
|
||||
* 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings)
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
if ($this->items !== null) {
|
||||
return $this->items;
|
||||
if ($this->rows !== null) {
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
// 本采购单订货明细(关联订单过滤软删)
|
||||
$orderItems = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order_item.purchase_id', $this->purchase->id)
|
||||
@@ -64,142 +92,298 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
->whereIn('id', $orderItems->pluck('product_id')->unique())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$rows = [];
|
||||
// 明细按商品聚合(快照字段取首条;有订货的商品以快照供应商为准)
|
||||
$itemGroups = [];
|
||||
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
|
||||
$first = $group->first();
|
||||
$product = $products->get((int) $productId);
|
||||
$unitCost = $first->cost_price ?? '0';
|
||||
|
||||
$quantity = '0';
|
||||
$weight = '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) {
|
||||
$quantity = bcadd($quantity, (string) $item->quantity, 2);
|
||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||
// 金额 = 数量 × 每包成本价(单价/包规不参与金额计算)
|
||||
$amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2);
|
||||
$storeQuantities[(int) $item->store_id] = bcadd(
|
||||
$storeQuantities[(int) $item->store_id] ?? '0',
|
||||
(string) $item->quantity,
|
||||
2
|
||||
);
|
||||
$storeQuantities[(int) $item->store_id] = ($storeQuantities[(int) $item->store_id] ?? 0) + (int) $item->quantity;
|
||||
}
|
||||
$itemGroups[(int) $productId] = [
|
||||
'snapshot' => $first,
|
||||
'quantity' => $quantity,
|
||||
'weight' => $weight,
|
||||
'amount' => $amount,
|
||||
'store_quantities' => $storeQuantities,
|
||||
];
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
// 导出范围:系统全部未删除商品(含下架)∪ 本采购单有订货的商品(含已删)
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
->whereNull('deleted_at')
|
||||
->orWhereIn('id', array_keys($itemGroups))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$categoryNames = $this->rootCategoryNames($products->pluck('category_id', 'id')->all());
|
||||
$supplierNames = SupplierModel::withTrashed()->pluck('name', 'id')->toArray();
|
||||
|
||||
// 组装商品行(供应商筛选:有订货按快照 supplier_id,无订货按档案 supplier_id)
|
||||
$items = [];
|
||||
foreach ($products as $productId => $product) {
|
||||
$group = $itemGroups[(int) $productId] ?? null;
|
||||
$snapshot = $group['snapshot'] ?? null;
|
||||
$rowSupplierId = (int) ($snapshot->supplier_id ?? $product->supplier_id);
|
||||
if ($this->supplierId > 0 && $rowSupplierId !== $this->supplierId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quantity = $group['quantity'] ?? '0';
|
||||
$amount = $group['amount'] ?? '0';
|
||||
$spec = (string) ($snapshot->product_spec ?? $product->spec);
|
||||
|
||||
$items[] = [
|
||||
'product_id' => (int) $productId,
|
||||
'supplier_id' => (int) $first->supplier_id,
|
||||
'product_name' => $first->product_name,
|
||||
'product_spec' => $first->product_spec,
|
||||
'unit' => $first->unit,
|
||||
'cost_price' => (float) ($first->cost_price ?? 0),
|
||||
'unit_cost' => (float) $unitCost,
|
||||
'category' => $categoryNames[(int) $productId] ?? '',
|
||||
'product_name' => (string) ($snapshot->product_name ?? $product->name),
|
||||
'supplier' => $supplierNames[$rowSupplierId] ?? '',
|
||||
'market' => (string) ($product->market ?? ''),
|
||||
'product_spec' => $spec,
|
||||
'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,
|
||||
'weight' => (float) $weight,
|
||||
'weight' => (float) ($group['weight'] ?? '0'),
|
||||
'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),
|
||||
'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']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
$items = collect($rows);
|
||||
$this->loadLookups($items);
|
||||
|
||||
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();
|
||||
if ($items === []) {
|
||||
throw new RepositoryException('该供应商在系统中无商品行,无法导出');
|
||||
}
|
||||
|
||||
$sort = 1;
|
||||
return $this->items = $items->map(static function (array $row) use (&$sort): array {
|
||||
$row['sort'] = $sort++;
|
||||
return $row;
|
||||
})->values();
|
||||
// ===== 手工建行 =====
|
||||
$rows = [];
|
||||
$rowIndex = 0;
|
||||
|
||||
// 标题行
|
||||
$rows[] = ['采购单 ' . $this->purchase->purchase_no . '(采购日期 ' . $this->purchase->purchase_date->toDateString() . ')'];
|
||||
$this->specialRows[++$rowIndex] = 'title';
|
||||
|
||||
// 范围行:供应商筛选 / 导出时间
|
||||
$scopeSupplier = '全部';
|
||||
if ($this->supplierId > 0) {
|
||||
$scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId);
|
||||
}
|
||||
$rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
|
||||
$this->specialRows[++$rowIndex] = 'scope';
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
$this->collection();
|
||||
// 空行
|
||||
$rows[] = [''];
|
||||
$rowIndex++;
|
||||
|
||||
return array_merge(
|
||||
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
||||
array_map(static fn (string $name): string => $name . '(数量)', array_values($this->storeNames)),
|
||||
// 列头
|
||||
$rows[] = array_merge(
|
||||
['序号', '分类', '品名', '供应商', '市场', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
||||
array_values($this->storeNames),
|
||||
);
|
||||
$this->specialRows[++$rowIndex] = 'header';
|
||||
$this->headerRow = $rowIndex;
|
||||
|
||||
// 明细行
|
||||
$totalQuantity = '0';
|
||||
$totalWeight = '0';
|
||||
$totalAmount = '0';
|
||||
$storeTotals = array_fill_keys(array_keys($this->storeNames), 0);
|
||||
foreach (array_values($items) as $sort => $item) {
|
||||
$this->rowData[++$rowIndex] = [
|
||||
'quantity' => $item['quantity'],
|
||||
'amount' => $item['amount'],
|
||||
'store_quantities' => $item['store_quantities'],
|
||||
];
|
||||
$rows[] = array_merge([
|
||||
$sort + 1,
|
||||
$item['category'],
|
||||
$item['product_name'],
|
||||
$item['supplier'],
|
||||
$item['market'],
|
||||
$item['product_spec'],
|
||||
$item['unit'],
|
||||
$item['cost_price'],
|
||||
$item['retail_price'],
|
||||
$item['quantity'],
|
||||
$item['weight'],
|
||||
$item['amount'],
|
||||
], array_map(
|
||||
static fn (int $qty): int|string => $qty > 0 ? $qty : '',
|
||||
array_values($item['store_quantities']),
|
||||
));
|
||||
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
|
||||
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
||||
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
||||
foreach ($item['store_quantities'] as $storeId => $qty) {
|
||||
$storeTotals[$storeId] += $qty;
|
||||
}
|
||||
}
|
||||
|
||||
public function map($row): array
|
||||
{
|
||||
return array_merge([
|
||||
$row['sort'],
|
||||
$this->categoryNames[$row['product_id']] ?? '',
|
||||
$row['product_name'],
|
||||
$this->supplierNames[$row['supplier_id']] ?? '',
|
||||
$row['product_spec'],
|
||||
$row['unit'],
|
||||
$row['cost_price'],
|
||||
$row['unit_cost'],
|
||||
$row['quantity'],
|
||||
$row['weight'],
|
||||
$row['amount'],
|
||||
], array_values($row['store_quantities']));
|
||||
// 合计行(行合计 + 门店列合计;门店无数据显示空白)
|
||||
$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
|
||||
{
|
||||
$sheet->freezePane('A2');
|
||||
$this->collection();
|
||||
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||
// 市场/数量列宽减半
|
||||
$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');
|
||||
$this->categoryNames = [];
|
||||
$names = [];
|
||||
foreach ($productCategoryIds as $productId => $categoryId) {
|
||||
$rootName = '';
|
||||
$cursor = (int) $categoryId;
|
||||
@@ -212,7 +396,8 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
||||
$rootName = $category->name;
|
||||
$cursor = (int) $category->parent_id;
|
||||
}
|
||||
$this->categoryNames[(int) $productId] = $rootName;
|
||||
$names[(int) $productId] = $rootName;
|
||||
}
|
||||
return $names;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
|
||||
/**
|
||||
* 门店购买详情导出:多门店合并为一个 XLSX(每门店一个工作表,工作表名=门店名称);
|
||||
* 构造传入 storeId 时仅导出该门店(单工作表)
|
||||
*/
|
||||
class PurchaseStoreExport implements WithMultipleSheets
|
||||
{
|
||||
/**
|
||||
* @param PurchaseOrderModel $purchase 采购单
|
||||
* @param int $storeId 单门店导出(0=全部门店)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly PurchaseOrderModel $purchase,
|
||||
private readonly int $storeId = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, PurchaseStoreSheet>
|
||||
*/
|
||||
public function sheets(): array
|
||||
{
|
||||
// 本采购单内有明细的门店(含软删除,保证历史单据可导出)
|
||||
$query = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order_item.purchase_id', $this->purchase->id)
|
||||
->whereNull('store_order.deleted_at');
|
||||
if ($this->storeId > 0) {
|
||||
$query->where('store_order_item.store_id', $this->storeId);
|
||||
}
|
||||
$storeIds = $query->distinct()->pluck('store_order_item.store_id');
|
||||
|
||||
if ($storeIds->isEmpty()) {
|
||||
throw new RepositoryException(
|
||||
$this->storeId > 0 ? '该门店在此采购单中无采购商品' : '该采购单无门店采购明细,无法导出'
|
||||
);
|
||||
}
|
||||
|
||||
$stores = StoreModel::withTrashed()
|
||||
->whereIn('id', $storeIds)
|
||||
->orderBy('id')
|
||||
->get(['id', 'name']);
|
||||
|
||||
$usedNames = [];
|
||||
$sheets = [];
|
||||
foreach ($stores as $store) {
|
||||
$sheets[] = new PurchaseStoreSheet(
|
||||
$this->purchase,
|
||||
$store,
|
||||
SheetName::make((string) $store->name, (int) $store->id, $usedNames),
|
||||
);
|
||||
}
|
||||
|
||||
return $sheets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Services\PurchaseItemService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 门店购买详情导出 · 单门店工作表
|
||||
*/
|
||||
class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
|
||||
{
|
||||
/** 数量列填充色(浅黄) */
|
||||
private const string QUANTITY_FILL = 'FFFFF2CC';
|
||||
|
||||
/** 金额列填充色(浅红) */
|
||||
private const string AMOUNT_FILL = 'FFFFCCCC';
|
||||
|
||||
/** 金额格式:¥ + 两位小数 */
|
||||
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
|
||||
|
||||
private ?Collection $rows = null;
|
||||
|
||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary) */
|
||||
private array $specialRows = [];
|
||||
|
||||
/** @var array<int, array{quantity: int, amount: float}> 明细行索引 => 填色判断数据 */
|
||||
private array $rowData = [];
|
||||
|
||||
/** @var array{quantity: int, amount: float} 合计行填色判断数据 */
|
||||
private array $summaryTotals = ['quantity' => 0, 'amount' => 0.0];
|
||||
|
||||
/** 列头所在行索引 */
|
||||
private int $headerRow = 1;
|
||||
|
||||
/** 数据末行索引 */
|
||||
private int $lastRow = 1;
|
||||
|
||||
public function __construct(
|
||||
private readonly PurchaseOrderModel $purchase,
|
||||
private readonly StoreModel $store,
|
||||
private readonly string $sheetTitle,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出行:标题/空行/列头/明细/合计
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
if ($this->rows !== null) {
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
$items = app(PurchaseItemService::class)->storeSummaryRows($this->purchase->id, $this->store->id);
|
||||
|
||||
$rows = [];
|
||||
$rowIndex = 0;
|
||||
|
||||
// 标题行
|
||||
$rows[] = [$this->store->name . ' · 采购单 ' . $this->purchase->purchase_no];
|
||||
$this->specialRows[++$rowIndex] = 'title';
|
||||
|
||||
// 空行
|
||||
$rows[] = [''];
|
||||
$rowIndex++;
|
||||
|
||||
// 列头
|
||||
$rows[] = ['序号', '品名', '市场', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额'];
|
||||
$this->specialRows[++$rowIndex] = 'header';
|
||||
$this->headerRow = $rowIndex;
|
||||
|
||||
// 明细行
|
||||
$totalQuantity = 0;
|
||||
$totalWeight = '0';
|
||||
$totalAmount = '0';
|
||||
foreach (array_values($items) as $sort => $item) {
|
||||
$this->rowData[++$rowIndex] = [
|
||||
'quantity' => (int) $item['quantity'],
|
||||
'amount' => (float) $item['amount'],
|
||||
];
|
||||
$rows[] = [
|
||||
$sort + 1,
|
||||
$item['product_name'],
|
||||
$item['market'],
|
||||
$item['product_spec'],
|
||||
$item['unit'],
|
||||
(float) $item['price'],
|
||||
(int) $item['quantity'],
|
||||
(float) $item['weight'],
|
||||
(float) $item['amount'],
|
||||
];
|
||||
$totalQuantity += (int) $item['quantity'];
|
||||
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
||||
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
||||
}
|
||||
|
||||
// 合计行
|
||||
$rows[] = ['', '合计', '', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
||||
$this->specialRows[++$rowIndex] = 'summary';
|
||||
$this->lastRow = $rowIndex;
|
||||
$this->summaryTotals = ['quantity' => $totalQuantity, 'amount' => (float) $totalAmount];
|
||||
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return $this->sheetTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
|
||||
* 数量(浅黄)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$this->collection();
|
||||
|
||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||
$widths = [6, 24, 12, 14, 8, 10, 10, 12, 12];
|
||||
foreach ($widths as $index => $width) {
|
||||
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($index + 1))
|
||||
->setWidth($width);
|
||||
}
|
||||
|
||||
// 全部单元格水平/垂直居中
|
||||
$sheet->getStyle('A1:I' . $this->lastRow)
|
||||
->getAlignment()
|
||||
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||
->setVertical(Alignment::VERTICAL_CENTER);
|
||||
|
||||
// 表格区域(列头 → 合计行)添加所有边框
|
||||
$sheet->getStyle('A' . $this->headerRow . ':I' . $this->lastRow)
|
||||
->getBorders()
|
||||
->getAllBorders()
|
||||
->setBorderStyle(Border::BORDER_THIN);
|
||||
|
||||
// 金额类列(单价/预计金额)货币格式:¥ + 两位小数
|
||||
foreach (['F', 'I'] as $column) {
|
||||
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow)
|
||||
->getNumberFormat()
|
||||
->setFormatCode(self::CURRENCY_FORMAT);
|
||||
}
|
||||
|
||||
// 数量列(G):有数据浅黄,0 无背景,列头固定浅黄
|
||||
$this->fillCell($sheet, 'G' . $this->headerRow, self::QUANTITY_FILL);
|
||||
foreach ($this->rowData as $row => $data) {
|
||||
if ($data['quantity'] > 0) {
|
||||
$this->fillCell($sheet, 'G' . $row, self::QUANTITY_FILL);
|
||||
}
|
||||
}
|
||||
if ($this->summaryTotals['quantity'] > 0) {
|
||||
$this->fillCell($sheet, 'G' . $this->lastRow, self::QUANTITY_FILL);
|
||||
}
|
||||
|
||||
// 金额列(I):有数据浅红,0 无背景,列头固定浅红
|
||||
$this->fillCell($sheet, 'I' . $this->headerRow, self::AMOUNT_FILL);
|
||||
foreach ($this->rowData as $row => $data) {
|
||||
if ($data['amount'] > 0) {
|
||||
$this->fillCell($sheet, 'I' . $row, self::AMOUNT_FILL);
|
||||
}
|
||||
}
|
||||
if ($this->summaryTotals['amount'] > 0) {
|
||||
$this->fillCell($sheet, 'I' . $this->lastRow, self::AMOUNT_FILL);
|
||||
}
|
||||
|
||||
$styles = [];
|
||||
foreach ($this->specialRows as $row => $type) {
|
||||
$style = match ($type) {
|
||||
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||
'header', 'summary' => ['font' => ['bold' => true]],
|
||||
default => [],
|
||||
};
|
||||
if ($style !== []) {
|
||||
$styles[$row] = $style;
|
||||
}
|
||||
}
|
||||
|
||||
return $styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元格纯色填充
|
||||
*/
|
||||
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||
{
|
||||
$sheet->getStyle($coordinate)
|
||||
->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()
|
||||
->setARGB($argb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Services\PurchaseItemService;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
|
||||
/**
|
||||
* 供应商采购明细导出:多供应商合并为一个 XLSX,按「供应商 × 市场」拆分工作表
|
||||
* (工作表名=供应商·市场,市场取商品档案,未设置市场归入「未设置」);
|
||||
* 构造传入 supplierId 时仅导出该供应商
|
||||
*/
|
||||
class PurchaseSupplierExport implements WithMultipleSheets
|
||||
{
|
||||
/**
|
||||
* @param PurchaseOrderModel $purchase 采购单
|
||||
* @param int $supplierId 单供应商导出(0=全部供应商)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly PurchaseOrderModel $purchase,
|
||||
private readonly int $supplierId = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, PurchaseSupplierSheet>
|
||||
*/
|
||||
public function sheets(): array
|
||||
{
|
||||
// 本采购单内有明细的供应商(按订货明细快照 supplier_id 归集)
|
||||
$query = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order_item.purchase_id', $this->purchase->id)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->where('store_order_item.supplier_id', '>', 0);
|
||||
if ($this->supplierId > 0) {
|
||||
$query->where('store_order_item.supplier_id', $this->supplierId);
|
||||
}
|
||||
$supplierIds = $query->distinct()->pluck('store_order_item.supplier_id');
|
||||
|
||||
if ($supplierIds->isEmpty()) {
|
||||
throw new RepositoryException(
|
||||
$this->supplierId > 0 ? '该供应商在此采购单中无采购明细' : '该采购单无供应商采购明细,无法导出'
|
||||
);
|
||||
}
|
||||
|
||||
$suppliers = SupplierModel::withTrashed()
|
||||
->whereIn('id', $supplierIds)
|
||||
->orderBy('id')
|
||||
->get(['id', 'name']);
|
||||
|
||||
$service = app(PurchaseItemService::class);
|
||||
$usedNames = [];
|
||||
$sheets = [];
|
||||
foreach ($suppliers as $supplier) {
|
||||
// 按市场拆分工作表
|
||||
$matrix = $service->supplierMarketMatrix($this->purchase->id, (int) $supplier->id);
|
||||
foreach ($matrix['markets'] as $market => $rows) {
|
||||
$marketLabel = $market === '' ? '未设置' : $market;
|
||||
$sheets[] = new PurchaseSupplierSheet(
|
||||
$this->purchase,
|
||||
$supplier,
|
||||
$marketLabel,
|
||||
$rows,
|
||||
$matrix['stores'],
|
||||
SheetName::make($supplier->name . '·' . $marketLabel, (int) $supplier->id, $usedNames),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $sheets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 供应商采购明细导出 · 单市场工作表
|
||||
* 模板列序:品名、汇总、市场、各门店明细(门店列=该供应商有明细的门店);
|
||||
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色)
|
||||
*/
|
||||
class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
|
||||
{
|
||||
/** 汇总列填充色(浅黄) */
|
||||
private const string QUANTITY_FILL = 'FFFFF2CC';
|
||||
|
||||
/** 门店数量列填充色(浅蓝) */
|
||||
private const string STORE_FILL = 'FFDDEBF7';
|
||||
|
||||
private ?Collection $rows = null;
|
||||
|
||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header) */
|
||||
private array $specialRows = [];
|
||||
|
||||
/** 列头所在行索引 */
|
||||
private int $headerRow = 1;
|
||||
|
||||
/** 数据末行索引 */
|
||||
private int $lastRow = 1;
|
||||
|
||||
/** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
|
||||
private array $rowData = [];
|
||||
|
||||
/**
|
||||
* @param PurchaseOrderModel $purchase 采购单
|
||||
* @param SupplierModel $supplier 供应商
|
||||
* @param string $marketLabel 市场名(空市场已转为「未设置」)
|
||||
* @param array<int, array<string, mixed>> $productRows 商品行(品名/汇总数量/各门店数量)
|
||||
* @param array<int, string> $stores 门店列(门店ID => 名称)
|
||||
* @param string $sheetTitle 工作表名
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly PurchaseOrderModel $purchase,
|
||||
private readonly SupplierModel $supplier,
|
||||
private readonly string $marketLabel,
|
||||
private readonly array $productRows,
|
||||
private readonly array $stores,
|
||||
private readonly string $sheetTitle,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出行:标题+备注(合并区)/空行(合并区)/列头(品名、汇总、市场、各门店)/明细
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
if ($this->rows !== null) {
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
$storeIds = array_map('intval', array_keys($this->stores));
|
||||
|
||||
$rows = [];
|
||||
$rowIndex = 0;
|
||||
|
||||
// 标题行(A1:C2 合并占两行),D1 起放采购单备注(D1:I2 合并,红色 22 号字)
|
||||
$rows[] = [
|
||||
$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no,
|
||||
'',
|
||||
'',
|
||||
trim((string) $this->purchase->remark),
|
||||
];
|
||||
$this->specialRows[++$rowIndex] = 'title';
|
||||
|
||||
// 空行(被标题/备注合并区域覆盖)
|
||||
$rows[] = [''];
|
||||
$rowIndex++;
|
||||
|
||||
// 列头:品名、汇总、市场、各门店明细
|
||||
$rows[] = array_merge(['品名', '汇总', '市场'], array_values($this->stores));
|
||||
$this->specialRows[++$rowIndex] = 'header';
|
||||
$this->headerRow = $rowIndex;
|
||||
|
||||
// 明细行(0 数量的门店格留空不填色)
|
||||
foreach ($this->productRows as $productRow) {
|
||||
$line = [
|
||||
$productRow['product_name'],
|
||||
(int) $productRow['quantity'],
|
||||
$this->marketLabel,
|
||||
];
|
||||
$storeQuantities = [];
|
||||
foreach ($storeIds as $storeId) {
|
||||
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
|
||||
$line[] = $quantity > 0 ? $quantity : '';
|
||||
$storeQuantities[$storeId] = $quantity;
|
||||
}
|
||||
$rows[] = $line;
|
||||
$this->rowData[++$rowIndex] = [
|
||||
'quantity' => (int) $productRow['quantity'],
|
||||
'store_quantities' => $storeQuantities,
|
||||
];
|
||||
}
|
||||
$this->lastRow = $rowIndex;
|
||||
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return $this->sheetTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题合并 A1:C2、备注合并 D1:I2(红色 22 号字);标题/列头加粗;全表居中 + 全边框 + 列头行自动换行;
|
||||
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$this->collection();
|
||||
|
||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||
|
||||
// 标题合并前三列占两行(A1:C2),备注合并六列两行(D1:I2)
|
||||
$sheet->mergeCells('A1:C2');
|
||||
$sheet->mergeCells('D1:I2');
|
||||
|
||||
$widths = [24, 10, 6];
|
||||
foreach ($widths as $index => $width) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
$storeIds = array_map('intval', array_keys($this->stores));
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(3 + max(count($storeIds), 1));
|
||||
foreach ($storeIds as $i => $storeId) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(6);
|
||||
}
|
||||
|
||||
// 全部单元格水平/垂直居中
|
||||
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
|
||||
->getAlignment()
|
||||
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||
->setVertical(Alignment::VERTICAL_CENTER);
|
||||
|
||||
// 列头行自动换行(门店列窄,门店名换行显示)
|
||||
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->headerRow)
|
||||
->getAlignment()
|
||||
->setWrapText(true);
|
||||
|
||||
// 备注合并单元格自动换行(长备注多行显示)
|
||||
$sheet->getStyle('D1')->getAlignment()->setWrapText(true);
|
||||
|
||||
// 表格区域(列头 → 数据末行)添加所有边框
|
||||
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
|
||||
->getBorders()
|
||||
->getAllBorders()
|
||||
->setBorderStyle(Border::BORDER_THIN);
|
||||
|
||||
// 汇总列(B):有数据浅黄,0 无背景,列头固定浅黄
|
||||
$this->fillCell($sheet, 'B' . $this->headerRow, self::QUANTITY_FILL);
|
||||
foreach ($this->rowData as $row => $data) {
|
||||
if ($data['quantity'] > 0) {
|
||||
$this->fillCell($sheet, 'B' . $row, self::QUANTITY_FILL);
|
||||
}
|
||||
}
|
||||
|
||||
// 门店数量列(D 起):有数据浅蓝,0 无背景,列头固定浅蓝
|
||||
foreach ($storeIds as $i => $storeId) {
|
||||
$column = Coordinate::stringFromColumnIndex(4 + $i);
|
||||
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
|
||||
foreach ($this->rowData as $row => $data) {
|
||||
if (($data['store_quantities'][$storeId] ?? 0) > 0) {
|
||||
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$styles = [];
|
||||
foreach ($this->specialRows as $row => $type) {
|
||||
$style = match ($type) {
|
||||
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||
'header' => ['font' => ['bold' => true]],
|
||||
default => [],
|
||||
};
|
||||
if ($style !== []) {
|
||||
$styles[$row] = $style;
|
||||
}
|
||||
}
|
||||
|
||||
// 备注单元格(D1):红色 22 号字,须放在行样式之后应用以覆盖标题行字号
|
||||
$styles['D1'] = ['font' => ['bold' => true, 'size' => 22, 'color' => ['argb' => 'FFFF0000']]];
|
||||
|
||||
return $styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元格纯色填充
|
||||
*/
|
||||
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||
{
|
||||
$sheet->getStyle($coordinate)
|
||||
->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()
|
||||
->setARGB($argb);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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\Http\Requests\Customer\CustomerLevelFormRequest;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\StoreModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
@@ -17,7 +20,7 @@ use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 客户等级管理(同一商品按客户等级定价)
|
||||
* 客户等级管理(同一商品按等级上浮比例定价:售价 = 成本价 × (100 + percent) / 100)
|
||||
*/
|
||||
#[RequestAttribute('/customer/level', 'customer.level')]
|
||||
class CustomerLevelController extends BaseController
|
||||
@@ -63,7 +66,7 @@ class CustomerLevelController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 编辑等级 */
|
||||
/** 编辑等级(上浮比例变更时通知该等级下门店用户) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, CustomerLevelFormRequest $request): JsonResponse
|
||||
{
|
||||
@@ -71,7 +74,14 @@ class CustomerLevelController extends BaseController
|
||||
if (empty($model)) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -86,9 +96,6 @@ class CustomerLevelController extends BaseController
|
||||
if ($model->stores()->exists()) {
|
||||
throw new RepositoryException('该等级下存在门店,无法删除');
|
||||
}
|
||||
if ($model->prices()->exists()) {
|
||||
throw new RepositoryException('该等级下存在商品价格,无法删除');
|
||||
}
|
||||
$model->delete();
|
||||
return $this->success();
|
||||
}
|
||||
@@ -100,8 +107,36 @@ class CustomerLevelController extends BaseController
|
||||
$data = CustomerLevelModel::query()
|
||||
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
||||
->orderBy('sort')
|
||||
->get(['id', 'name'])
|
||||
->get(['id', 'name', 'percent'])
|
||||
->toArray();
|
||||
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;
|
||||
|
||||
/**
|
||||
* 通知管理(小程序端消息;user_id=0 为全员广播)
|
||||
* 通知管理(小程序端消息;store_id=0 为全员广播)
|
||||
*/
|
||||
#[RequestAttribute('/customer/notice', 'customer.notice')]
|
||||
class NoticeController extends BaseController
|
||||
@@ -22,7 +22,7 @@ class NoticeController extends BaseController
|
||||
protected array $searchField = [
|
||||
'type' => '=',
|
||||
'is_read' => '=',
|
||||
'user_id' => '=',
|
||||
'store_id' => '=',
|
||||
];
|
||||
|
||||
protected array $quickSearchField = ['title', 'content'];
|
||||
@@ -40,7 +40,7 @@ class NoticeController extends BaseController
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 发布通知(user_id=0 全员广播) */
|
||||
/** 发布通知(store_id=0 全员广播) */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(NoticeFormRequest $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
|
||||
/**
|
||||
* 门店管理(小程序下单主体,即客户)
|
||||
* 门店管理(小程序下单主体,即客户;门店账号即小程序登录账号)
|
||||
*/
|
||||
#[RequestAttribute('/customer/store', 'customer.store')]
|
||||
class StoreController extends BaseController
|
||||
@@ -23,13 +23,14 @@ class StoreController extends BaseController
|
||||
protected array $searchField = [
|
||||
'name' => 'like',
|
||||
'code' => 'like',
|
||||
'username' => 'like',
|
||||
'level_id' => '=',
|
||||
'status' => '=',
|
||||
];
|
||||
|
||||
protected array $quickSearchField = ['name', 'code', 'contact', 'phone'];
|
||||
protected array $quickSearchField = ['name', 'code', 'username', 'contact', 'phone'];
|
||||
|
||||
/** 门店列表(含等级名回显) */
|
||||
/** 门店列表(含等级名回显;密码永不回显) */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
@@ -42,17 +43,18 @@ class StoreController extends BaseController
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 创建门店 */
|
||||
/** 创建门店(同时设置小程序登录账号与初始密码) */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(StoreFormRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$validated['code'] = generate_unique_code(StoreModel::class);
|
||||
$validated['password'] = password_hash((string) $validated['password'], PASSWORD_DEFAULT);
|
||||
StoreModel::create($validated);
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 编辑门店 */
|
||||
/** 编辑门店(密码留空则不修改) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, StoreFormRequest $request): JsonResponse
|
||||
{
|
||||
@@ -60,7 +62,13 @@ class StoreController extends BaseController
|
||||
if (empty($model)) {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Models\BillModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
|
||||
/**
|
||||
* 仪表盘统计(分析页 /dashboard/analysis 数据源)
|
||||
*
|
||||
* 口径约定:
|
||||
* - 销售/订单/品类/热销等统计均排除「已取消」订单与软删除订单
|
||||
* - 采购金额按实际采购价计(actual_amount 为 0 时退回预估价 estimate_amount)
|
||||
* - 应收余额 = 账单总额 - 已支付账单金额(审核中回款不计入已收)
|
||||
*/
|
||||
#[RequestAttribute('/dashboard', 'dashboard')]
|
||||
class DashboardController extends BaseController
|
||||
{
|
||||
/** 销售趋势统计天数 */
|
||||
private const int TREND_DAYS = 30;
|
||||
|
||||
/** 排行榜条数上限 */
|
||||
private const int TOP_LIMIT = 10;
|
||||
|
||||
/** 最新订单条数 */
|
||||
private const int LATEST_ORDER_LIMIT = 8;
|
||||
|
||||
/** 分析页聚合数据(单接口一次返回,减少首页请求数) */
|
||||
#[GetRoute('/analysis', 'analysis')]
|
||||
public function analysis(): JsonResponse
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
|
||||
return $this->success([
|
||||
'overview' => $this->overview(),
|
||||
'trend' => $this->salesTrend($today),
|
||||
'order_status' => $this->orderStatusDistribution(),
|
||||
'category_sales' => $this->categorySales(),
|
||||
'top_products' => $this->topProducts(),
|
||||
'top_stores' => $this->topStores(),
|
||||
'recon' => $this->reconSummary(),
|
||||
'latest_orders' => $this->latestOrders(),
|
||||
'archives' => $this->archives(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心指标卡:销售额 / 订单数 / 采购金额(累计、今日、近7天、环比、7日迷你趋势)+ 应收余额
|
||||
*
|
||||
* @return array{
|
||||
* sales: array{total: float, today: float, week: float, growth: float|null, trend7: float[]},
|
||||
* orders: array{total: int, today: int, week: int, growth: float|null, trend7: int[]},
|
||||
* purchase: array{total: float, today: float, week: float, growth: float|null, trend7: float[]},
|
||||
* receivable: array{bill_total: float, received: float, unreceived: float}
|
||||
* }
|
||||
*/
|
||||
private function overview(): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$weekStart = now()->subDays(6)->toDateString();
|
||||
$lastWeekStart = now()->subDays(13)->toDateString();
|
||||
$lastWeekEnd = now()->subDays(7)->toDateString();
|
||||
|
||||
// 近14天按日聚合(前7天用于环比,后7天用于迷你趋势图)
|
||||
$daily = $this->validOrderQuery()
|
||||
->whereBetween('order_date', [$lastWeekStart, $today])
|
||||
->get(['order_date', 'total_amount'])
|
||||
->groupBy(fn (StoreOrderModel $order) => $order->order_date->toDateString());
|
||||
|
||||
$salesByDay = [];
|
||||
$ordersByDay = [];
|
||||
foreach ($daily as $date => $orders) {
|
||||
$salesByDay[$date] = (float) $orders->sum('total_amount');
|
||||
$ordersByDay[$date] = $orders->count();
|
||||
}
|
||||
|
||||
$salesWeek = $this->sumRange($salesByDay, $weekStart, $today);
|
||||
$salesLastWeek = $this->sumRange($salesByDay, $lastWeekStart, $lastWeekEnd);
|
||||
$ordersWeek = (int) $this->sumRange($ordersByDay, $weekStart, $today);
|
||||
$ordersLastWeek = (int) $this->sumRange($ordersByDay, $lastWeekStart, $lastWeekEnd);
|
||||
|
||||
// 采购金额:实际价优先,无实际价用预估价
|
||||
$purchaseDaily = PurchaseOrderModel::query()
|
||||
->whereBetween('purchase_date', [$lastWeekStart, $today])
|
||||
->get(['purchase_date', 'estimate_amount', 'actual_amount'])
|
||||
->groupBy(fn (PurchaseOrderModel $purchase) => $purchase->purchase_date->toDateString())
|
||||
->map(fn ($purchases) => (float) $purchases->sum(
|
||||
fn (PurchaseOrderModel $purchase) => (float) $purchase->actual_amount > 0
|
||||
? $purchase->actual_amount
|
||||
: $purchase->estimate_amount
|
||||
));
|
||||
|
||||
$purchaseWeek = $this->sumRange($purchaseDaily->all(), $weekStart, $today);
|
||||
$purchaseLastWeek = $this->sumRange($purchaseDaily->all(), $lastWeekStart, $lastWeekEnd);
|
||||
|
||||
// 应收余额:账单总额 - 已支付账单金额
|
||||
$billTotal = (float) BillModel::query()->sum('total_amount');
|
||||
$received = (float) BillModel::query()->where('status', BillModel::STATUS_PAID)->sum('total_amount');
|
||||
|
||||
return [
|
||||
'sales' => [
|
||||
'total' => $this->money($this->validOrderQuery()->sum('total_amount')),
|
||||
'today' => $this->money($salesByDay[$today] ?? 0),
|
||||
'week' => $this->money($salesWeek),
|
||||
'growth' => $this->growthRate($salesWeek, $salesLastWeek),
|
||||
'trend7' => array_map(
|
||||
fn ($day) => $this->money($salesByDay[$day] ?? 0),
|
||||
$this->dateRange($weekStart, $today)
|
||||
),
|
||||
],
|
||||
'orders' => [
|
||||
'total' => (int) $this->validOrderQuery()->count(),
|
||||
'today' => (int) ($ordersByDay[$today] ?? 0),
|
||||
'week' => $ordersWeek,
|
||||
'growth' => $this->growthRate((float) $ordersWeek, (float) $ordersLastWeek),
|
||||
'trend7' => array_map(
|
||||
static fn ($day) => (int) ($ordersByDay[$day] ?? 0),
|
||||
$this->dateRange($weekStart, $today)
|
||||
),
|
||||
],
|
||||
'purchase' => [
|
||||
'total' => $this->money(PurchaseOrderModel::query()->sum(DB::raw(
|
||||
'CASE WHEN actual_amount > 0 THEN actual_amount ELSE COALESCE(estimate_amount, 0) END'
|
||||
))),
|
||||
'today' => $this->money($purchaseDaily[$today] ?? 0),
|
||||
'week' => $this->money($purchaseWeek),
|
||||
'growth' => $this->growthRate($purchaseWeek, $purchaseLastWeek),
|
||||
'trend7' => array_map(
|
||||
fn ($day) => $this->money($purchaseDaily[$day] ?? 0),
|
||||
$this->dateRange($weekStart, $today)
|
||||
),
|
||||
],
|
||||
'receivable' => [
|
||||
'bill_total' => $this->money($billTotal),
|
||||
'received' => $this->money($received),
|
||||
'unreceived' => $this->money($billTotal - $received),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 近30天销售趋势(缺日补0)
|
||||
*
|
||||
* @return array<int, array{date: string, amount: float, orders: int}>
|
||||
*/
|
||||
private function salesTrend(string $today): array
|
||||
{
|
||||
$start = now()->subDays(self::TREND_DAYS - 1)->toDateString();
|
||||
|
||||
$daily = $this->validOrderQuery()
|
||||
->whereBetween('order_date', [$start, $today])
|
||||
->get(['order_date', 'total_amount'])
|
||||
->groupBy(fn (StoreOrderModel $order) => $order->order_date->toDateString());
|
||||
|
||||
return array_map(static function ($date) use ($daily) {
|
||||
$orders = $daily->get($date);
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'amount' => round((float) ($orders?->sum('total_amount') ?? 0), 2),
|
||||
'orders' => $orders?->count() ?? 0,
|
||||
];
|
||||
}, $this->dateRange($start, $today));
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单状态分布(含已取消,全部状态固定输出,缺省补0)
|
||||
*
|
||||
* @return array<int, array{status: int, name: string, count: int}>
|
||||
*/
|
||||
private function orderStatusDistribution(): array
|
||||
{
|
||||
$counts = StoreOrderModel::query()
|
||||
->selectRaw('status, COUNT(*) as cnt')
|
||||
->groupBy('status')
|
||||
->pluck('cnt', 'status');
|
||||
|
||||
$result = [];
|
||||
foreach (StoreOrderModel::STATUS_NAMES as $status => $name) {
|
||||
$result[] = [
|
||||
'status' => $status,
|
||||
'name' => $name,
|
||||
'count' => (int) ($counts[$status] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 品类销售占比(按明细金额汇总,金额降序)
|
||||
*
|
||||
* @return array<int, array{name: string, amount: float}>
|
||||
*/
|
||||
private function categorySales(): array
|
||||
{
|
||||
$rows = $this->validItemQuery()
|
||||
->groupBy('i.category_id')
|
||||
->selectRaw('i.category_id, SUM(i.amount) as amount')
|
||||
->orderByDesc('amount')
|
||||
->get();
|
||||
|
||||
$categoryNames = ProductCategoryModel::query()->pluck('name', 'id');
|
||||
|
||||
return $rows->map(static fn ($row) => [
|
||||
'name' => $categoryNames[$row->category_id] ?? '未分类',
|
||||
'amount' => round((float) $row->amount, 2),
|
||||
])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 热销商品 TOP10(按明细金额汇总;商品名/规格/单位取快照)
|
||||
*
|
||||
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, quantity: float, amount: float}>
|
||||
*/
|
||||
private function topProducts(): array
|
||||
{
|
||||
return $this->validItemQuery()
|
||||
->groupBy('i.product_id')
|
||||
->selectRaw(implode(', ', [
|
||||
'i.product_id',
|
||||
'MAX(i.product_name) as product_name',
|
||||
'MAX(i.product_spec) as product_spec',
|
||||
'MAX(i.unit) as unit',
|
||||
'SUM(i.quantity) as quantity',
|
||||
'SUM(i.amount) as amount',
|
||||
]))
|
||||
->orderByDesc('amount')
|
||||
->limit(self::TOP_LIMIT)
|
||||
->get()
|
||||
->map(static fn ($row) => [
|
||||
'product_id' => (int) $row->product_id,
|
||||
'product_name' => (string) $row->product_name,
|
||||
'product_spec' => (string) $row->product_spec,
|
||||
'unit' => (string) $row->unit,
|
||||
'quantity' => round((float) $row->quantity, 2),
|
||||
'amount' => round((float) $row->amount, 2),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店排行 TOP10(按订单金额汇总,含软删除门店名称回查)
|
||||
*
|
||||
* @return array<int, array{store_id: int, store_name: string, order_count: int, amount: float}>
|
||||
*/
|
||||
private function topStores(): array
|
||||
{
|
||||
$rows = $this->validOrderQuery()
|
||||
->groupBy('store_id')
|
||||
->selectRaw('store_id, COUNT(*) as order_count, SUM(total_amount) as amount')
|
||||
->orderByDesc('amount')
|
||||
->limit(self::TOP_LIMIT)
|
||||
->get();
|
||||
|
||||
$storeNames = StoreModel::withTrashed()
|
||||
->whereIn('id', $rows->pluck('store_id'))
|
||||
->pluck('name', 'id');
|
||||
|
||||
return $rows->map(static fn ($row) => [
|
||||
'store_id' => (int) $row->store_id,
|
||||
'store_name' => $storeNames[$row->store_id] ?? '未知门店',
|
||||
'order_count' => (int) $row->order_count,
|
||||
'amount' => round((float) $row->amount, 2),
|
||||
])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 对账与回款概览:账单支付进度分布 + 待审核回款
|
||||
*
|
||||
* @return array{
|
||||
* bills: array<int, array{pay_state: int, name: string, count: int, amount: float}>,
|
||||
* pending_payment: array{count: int, amount: float}
|
||||
* }
|
||||
*/
|
||||
private function reconSummary(): array
|
||||
{
|
||||
$states = [];
|
||||
foreach (BillModel::PAY_STATE_NAMES as $state => $name) {
|
||||
$states[$state] = ['pay_state' => $state, 'name' => $name, 'count' => 0, 'amount' => 0.0];
|
||||
}
|
||||
|
||||
BillModel::query()
|
||||
->get(['id', 'total_amount', 'status', 'payment_id'])
|
||||
->each(static function (BillModel $bill) use (&$states) {
|
||||
$state = $bill->payState();
|
||||
$states[$state]['count']++;
|
||||
$states[$state]['amount'] += (float) $bill->total_amount;
|
||||
});
|
||||
|
||||
$bills = array_values(array_map(
|
||||
fn ($state) => [
|
||||
'pay_state' => $state['pay_state'],
|
||||
'name' => $state['name'],
|
||||
'count' => $state['count'],
|
||||
'amount' => $this->money($state['amount']),
|
||||
],
|
||||
$states
|
||||
));
|
||||
|
||||
$pendingPayment = PaymentModel::query()->where('status', PaymentModel::STATUS_PENDING);
|
||||
|
||||
return [
|
||||
'bills' => $bills,
|
||||
'pending_payment' => [
|
||||
'count' => (int) $pendingPayment->count(),
|
||||
'amount' => $this->money($pendingPayment->sum('amount')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新订单(按下单日期倒序,含已取消,便于掌握最新动态)
|
||||
*
|
||||
* @return array<int, array{id: int, order_no: string, store_name: string, order_date: string, total_amount: float, status: int, status_name: string}>
|
||||
*/
|
||||
private function latestOrders(): array
|
||||
{
|
||||
return StoreOrderModel::query()
|
||||
->with('store:id,name')
|
||||
->orderByDesc('order_date')
|
||||
->orderByDesc('id')
|
||||
->limit(self::LATEST_ORDER_LIMIT)
|
||||
->get()
|
||||
->map(static fn (StoreOrderModel $order) => [
|
||||
'id' => $order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'store_name' => $order->store->name ?? '未知门店',
|
||||
'order_date' => $order->order_date->toDateString(),
|
||||
'total_amount' => round((float) $order->total_amount, 2),
|
||||
'status' => $order->status,
|
||||
'status_name' => StoreOrderModel::STATUS_NAMES[$order->status] ?? (string) $order->status,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础档案计数:在营门店 / 在售商品 / 合作供应商
|
||||
*
|
||||
* @return array{stores: int, products: int, suppliers: int}
|
||||
*/
|
||||
private function archives(): array
|
||||
{
|
||||
return [
|
||||
'stores' => StoreModel::query()->where('status', StoreModel::STATUS_NORMAL)->count(),
|
||||
'products' => ProductModel::query()->where('status', ProductModel::STATUS_ON)->count(),
|
||||
'suppliers' => SupplierModel::query()->where('status', SupplierModel::STATUS_NORMAL)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/** 有效订单查询(排除已取消;软删除由模型全局作用域排除) */
|
||||
private function validOrderQuery(): Builder
|
||||
{
|
||||
return StoreOrderModel::query()->where('status', '<>', StoreOrderModel::STATUS_CANCELLED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效订单明细查询(join 绕过模型全局作用域,需手动排除软删除订单与已取消订单)
|
||||
*/
|
||||
private function validItemQuery(): Builder
|
||||
{
|
||||
return StoreOrderItemModel::query()
|
||||
->from('store_order_item as i')
|
||||
->join('store_order as o', 'o.id', '=', 'i.order_id')
|
||||
->whereNull('o.deleted_at')
|
||||
->where('o.status', '<>', StoreOrderModel::STATUS_CANCELLED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日期区间(含端点)汇总每日数据,缺日按0计
|
||||
*
|
||||
* @param array<string, float|int> $daily 日期 => 当日值
|
||||
*/
|
||||
private function sumRange(array $daily, string $start, string $end): float
|
||||
{
|
||||
$sum = 0.0;
|
||||
foreach ($this->dateRange($start, $end) as $date) {
|
||||
$sum += (float) ($daily[$date] ?? 0);
|
||||
}
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成连续日期列表(含端点)
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function dateRange(string $start, string $end): array
|
||||
{
|
||||
$dates = [];
|
||||
$cursor = $start;
|
||||
while ($cursor <= $end) {
|
||||
$dates[] = $cursor;
|
||||
$cursor = date('Y-m-d', strtotime($cursor . ' +1 day'));
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/** 金额统一保留两位 */
|
||||
private function money(float|string|null $value): float
|
||||
{
|
||||
return round((float) $value, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 环比增长率(%):上期为0时无基准,返回 null
|
||||
*/
|
||||
private function growthRate(float $current, float $previous): ?float
|
||||
{
|
||||
if ($previous <= 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(($current - $previous) / $previous * 100, 1);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UserRegisterRequest;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Trait\RequestJson;
|
||||
|
||||
@@ -17,7 +12,7 @@ class IndexController
|
||||
{
|
||||
use RequestJson;
|
||||
// 权限验证白名单
|
||||
protected array $noPermission = ['index', 'login', 'register', 'mail'];
|
||||
protected array $noPermission = ['index'];
|
||||
|
||||
/** 获取首页信息 */
|
||||
#[GetRoute('/index')]
|
||||
@@ -27,37 +22,4 @@ class IndexController
|
||||
|
||||
return $this->success(compact('web_setting'));
|
||||
}
|
||||
|
||||
/** 用户登录 */
|
||||
#[PostRoute('/login')]
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'username' => 'required|min:4|alphaDash',
|
||||
'password' => 'required|min:4|alphaDash',
|
||||
]);
|
||||
if (Auth::guard('users')->attempt($credentials, true)) {
|
||||
$data = $request->user('users')
|
||||
->createToken($credentials['username'])
|
||||
->toArray();
|
||||
return $this->success($data, __('user.login_success'));
|
||||
}
|
||||
return $this->error(__('user.login_error'));
|
||||
}
|
||||
|
||||
/** 用户注册 */
|
||||
#[PostRoute('/register')]
|
||||
public function register(UserRegisterRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$model = new UserModel;
|
||||
$model->username = $data['username'];
|
||||
$model->password = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
$model->email = $data['email'];
|
||||
if ($model->save()) {
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
return $this->error('创建用户失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\UserUpdateInfoRequest;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\WechatService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
@@ -15,113 +12,79 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
|
||||
/**
|
||||
* 小程序认证
|
||||
* 小程序认证(门店 账号 + 密码 登录,不再使用微信能力)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class AuthController extends BaseMiniController
|
||||
{
|
||||
/** 小程序登录 */
|
||||
/** 门店登录(账号 + 密码) */
|
||||
#[PostRoute('/auth/login', authorize: false)]
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string'
|
||||
'username' => 'required|string|max:20',
|
||||
'password' => 'required|string|max:20',
|
||||
], [
|
||||
'code.required' => '登录参数格式错误',
|
||||
'code.string' => '登录参数格式错误',
|
||||
'username.required' => '请输入登录账号',
|
||||
'password.required' => '请输入登录密码',
|
||||
]);
|
||||
|
||||
$session = app(WechatService::class)->code2Session($validated['code']);
|
||||
$store = StoreModel::where('username', $validated['username'])->first();
|
||||
|
||||
$user = UserModel::where('openid', $session['openid'])->first();
|
||||
|
||||
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
|
||||
return $this->error('账号不存在或已被停用');
|
||||
if ($store === null || ! password_verify($validated['password'], (string) $store->password)) {
|
||||
return $this->error('账号或密码错误');
|
||||
}
|
||||
if ($store->status === StoreModel::STATUS_DISABLED) {
|
||||
return $this->error('账号已被停用,请联系客服处理');
|
||||
}
|
||||
|
||||
$user->last_login_at = date('Y-m-d H:i:s');
|
||||
$user->save();
|
||||
$token = $user->createToken($user->openid)->toArray();
|
||||
return $this->success([
|
||||
'token' => $token['plainTextToken'],
|
||||
'user' => $user->toArray(),
|
||||
], __('user.login_success'));
|
||||
$store->last_login_at = date('Y-m-d H:i:s');
|
||||
$store->save();
|
||||
|
||||
}
|
||||
|
||||
/** 小程序注册 */
|
||||
#[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();
|
||||
$token = $store->createToken($store->username)->toArray();
|
||||
$store->load('level:id,name');
|
||||
|
||||
return $this->success([
|
||||
'token' => $token['plainTextToken'],
|
||||
'user' => $user->toArray(),
|
||||
'user' => $store->toArray(),
|
||||
], __('user.login_success'));
|
||||
}
|
||||
|
||||
/** 当前用户信息 */
|
||||
/** 当前门店信息(含客户等级) */
|
||||
#[GetRoute('/auth/info')]
|
||||
public function info(Request $request): JsonResponse
|
||||
{
|
||||
$user = UserModel::with(['store.level:id,name'])
|
||||
->find($request->user()->id);
|
||||
if ($user === null) {
|
||||
$store = StoreModel::with('level:id,name')->find($request->user()->id);
|
||||
if ($store === null) {
|
||||
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\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemUser\Models\SysAccessToken;
|
||||
|
||||
/**
|
||||
* 小程序端控制器基类
|
||||
*
|
||||
* 无 #[RequestAttribute],不会被 AnnoRoute 注册为路由。
|
||||
* 提供当前用户获取与门店绑定前置校验。
|
||||
* 门店即用户:登录主体就是门店(store 表),提供当前门店获取与状态校验。
|
||||
*/
|
||||
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);
|
||||
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
|
||||
$store = StoreModel::find($request->user()->id);
|
||||
if ($store === null || $store->status === StoreModel::STATUS_DISABLED) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户当前绑定的正常门店(未绑定/已停用返回 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;
|
||||
}
|
||||
$store = StoreModel::find($user->store_id);
|
||||
if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) {
|
||||
$accessToken = SysAccessToken::findToken($token);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ namespace App\Http\Controllers\Mini;
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Mini\MiniCartRequest;
|
||||
use App\Models\CartModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Services\CartService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -34,9 +35,8 @@ class CartController extends BaseMiniController
|
||||
#[PostRoute('/cart')]
|
||||
public function store(MiniCartRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
if ($store->level_id <= 0) {
|
||||
$store = $this->currentStore($request);
|
||||
if ($store->level_id <= 0 || $store->level === null) {
|
||||
return $this->error('门店未设置客户等级,无法加购,请联系客服');
|
||||
}
|
||||
|
||||
@@ -45,20 +45,12 @@ class CartController extends BaseMiniController
|
||||
if ($product === null) {
|
||||
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');
|
||||
|
||||
$cart = DB::transaction(function () use ($user, $productId, $quantity) {
|
||||
$cart = DB::transaction(function () use ($store, $productId, $quantity) {
|
||||
$row = CartModel::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('store_id', $store->id)
|
||||
->where('product_id', $productId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
@@ -75,7 +67,7 @@ class CartController extends BaseMiniController
|
||||
}
|
||||
|
||||
return CartModel::create([
|
||||
'user_id' => $user->id,
|
||||
'store_id' => $store->id,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
]);
|
||||
@@ -93,11 +85,10 @@ class CartController extends BaseMiniController
|
||||
#[GetRoute('/cart', authorize: true)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$rows = CartModel::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('store_id', $store->id)
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
@@ -115,11 +106,8 @@ class CartController extends BaseMiniController
|
||||
->unique()->values()->all();
|
||||
|
||||
$products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->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');
|
||||
// 门店等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)
|
||||
$level = $store->level_id > 0 ? $store->level : null;
|
||||
|
||||
// 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。
|
||||
// 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值
|
||||
@@ -141,16 +129,10 @@ class CartController extends BaseMiniController
|
||||
foreach ($rows as $row) {
|
||||
$product = $products->get($row->product_id);
|
||||
$productOn = $product !== null && $product->status === ProductModel::STATUS_ON;
|
||||
// 实际价(百分比计价行按成本价上浮换算);未设等级价为 null
|
||||
$priceRow = $priceRows->get($row->product_id);
|
||||
$price = $priceRow === null
|
||||
// 实际价(按门店等级上浮比例换算);未设等级为 null
|
||||
$price = $level === null
|
||||
? null
|
||||
: ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
(float) ($product?->cost_price ?? 0),
|
||||
);
|
||||
: CustomerLevelModel::calcLevelPrice((float) ($product?->cost_price ?? 0), $level->percent);
|
||||
$buyable = $productOn && $price !== null;
|
||||
$quantity = (string) $row->quantity;
|
||||
|
||||
@@ -163,6 +145,7 @@ class CartController extends BaseMiniController
|
||||
'name' => $product->name ?? '',
|
||||
'spec' => $product->spec ?? '',
|
||||
'unit' => $product->unit ?? '',
|
||||
'price_unit' => $product->price_unit ?? '',
|
||||
'image' => $firstFile?->file_url ?? '',
|
||||
'price' => $price,
|
||||
'quantity' => $quantity,
|
||||
@@ -191,12 +174,11 @@ class CartController extends BaseMiniController
|
||||
#[PutRoute('/cart/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, MiniCartRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$row = CartModel::query()
|
||||
->where('id', $id)
|
||||
->where('user_id', $user->id)
|
||||
->where('store_id', $store->id)
|
||||
->first();
|
||||
if ($row === null) {
|
||||
throw new RepositoryException('购物车项不存在');
|
||||
@@ -214,12 +196,11 @@ class CartController extends BaseMiniController
|
||||
#[DeleteRoute('/cart/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function destroy(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$deleted = CartModel::query()
|
||||
->where('id', $id)
|
||||
->where('user_id', $user->id)
|
||||
->where('store_id', $store->id)
|
||||
->delete();
|
||||
if ($deleted === 0) {
|
||||
throw new RepositoryException('购物车项不存在');
|
||||
@@ -228,16 +209,24 @@ class CartController extends BaseMiniController
|
||||
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)]
|
||||
public function clear(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
CartModel::query()->where('user_id', $user->id)->delete();
|
||||
CartModel::query()->where('store_id', $store->id)->delete();
|
||||
|
||||
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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -11,31 +11,34 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
|
||||
/**
|
||||
* 小程序通知(本人通知 + 全员广播)
|
||||
* 小程序通知(本店通知 + 全员广播)
|
||||
*
|
||||
* 广播已读处理:user_id=0 的广播是全局共享记录,直接改 is_read 会影响其他用户,
|
||||
* 因此标记已读时复制一条本人专属的已读记录(data.broadcast_from 记来源),
|
||||
* 广播已读处理:store_id=0 的广播是全局共享记录,直接改 is_read 会影响其他门店,
|
||||
* 因此标记已读时复制一条本店专属的已读记录(data.broadcast_from 记来源),
|
||||
* 列表查询时排除已有已读副本的广播。
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class NoticeController extends BaseMiniController
|
||||
{
|
||||
/** 本人通知 + 全员广播(user_id in [0, 当前id]),分页 + unread_count */
|
||||
/** 本店通知 + 全员广播(store_id in [0, 当前id]),分页 + unread_count */
|
||||
#[GetRoute('/notice', authorize: true)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
// 本人已读过的广播来源ID(已读副本记录)
|
||||
// 本店已读过的广播来源ID(已读副本记录)
|
||||
$readBroadcastIds = NoticeModel::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('store_id', $store->id)
|
||||
->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) {
|
||||
$q->where('user_id', $user->id)
|
||||
$query = NoticeModel::query()->where(function ($q) use ($store, $readBroadcastIds) {
|
||||
$q->where('store_id', $store->id)
|
||||
->orWhere(function ($broadcastQuery) use ($readBroadcastIds) {
|
||||
$broadcastQuery->where('user_id', NoticeModel::BROADCAST_USER_ID);
|
||||
$broadcastQuery->where('store_id', NoticeModel::BROADCAST_STORE_ID);
|
||||
if ($readBroadcastIds->isNotEmpty()) {
|
||||
$broadcastQuery->whereNotIn('id', $readBroadcastIds->all());
|
||||
}
|
||||
@@ -52,24 +55,24 @@ class NoticeController extends BaseMiniController
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 标记已读(广播 → 复制本人已读副本;个人通知 → 直接更新) */
|
||||
/** 标记已读(广播 → 复制本店已读副本;定向通知 → 直接更新) */
|
||||
#[PutRoute('/notice/{id}/read', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
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) {
|
||||
throw new RepositoryException('通知不存在');
|
||||
}
|
||||
|
||||
if ($notice->user_id === NoticeModel::BROADCAST_USER_ID) {
|
||||
$exists = NoticeModel::where('user_id', $user->id)
|
||||
if ($notice->store_id === NoticeModel::BROADCAST_STORE_ID) {
|
||||
$exists = NoticeModel::where('store_id', $store->id)
|
||||
->where('data->broadcast_from', $notice->id)
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
NoticeModel::create([
|
||||
'user_id' => $user->id,
|
||||
'store_id' => $store->id,
|
||||
'type' => $notice->type,
|
||||
'title' => $notice->title,
|
||||
'content' => $notice->content,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Services\OnlinePaymentService;
|
||||
use App\Services\WangpuPayService;
|
||||
use App\Services\WechatPayService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3;场景二选一:小程序 / 公众号 H5)
|
||||
*
|
||||
* 链路:POST /mini/payment/online 下单(scene=mini 小程序 / scene=mp 公众号,返回调起支付参数)
|
||||
* → 小程序 wx.requestPayment / 公众号 H5 WeixinJSBridge 完成支付
|
||||
* → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账
|
||||
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class OnlinePaymentController extends BaseMiniController
|
||||
{
|
||||
public function __construct(
|
||||
protected OnlinePaymentService $onlinePayment,
|
||||
protected WangpuPayService $wangpu,
|
||||
protected WechatPayService $wxpay,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发起在线支付:合并选择本店未支付账单 → 渠道下单 → 返回调起支付参数
|
||||
*
|
||||
* scene=mini(默认):小程序 wx.login code 换 openid,wx.requestPayment 调起;
|
||||
* scene=mp:公众号 H5 网页授权 code 换 openid,WeixinJSBridge getBrandWCPayRequest 调起。
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[PostRoute('/payment/online', authorize: true)]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'bill_ids' => 'required|array|min:1',
|
||||
'bill_ids.*' => 'integer|distinct',
|
||||
'code' => 'required|string|max:64',
|
||||
'scene' => 'nullable|string|in:mini,mp',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
], [
|
||||
'bill_ids.required' => '请选择要付款的账单',
|
||||
'bill_ids.min' => '请选择要付款的账单',
|
||||
'code.required' => '微信登录/授权凭证缺失,请重新进入',
|
||||
'scene.in' => '支付场景不正确',
|
||||
'remark.max' => '备注超过最大长度',
|
||||
]);
|
||||
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
[$payment, $payParams] = $this->onlinePayment->create(
|
||||
$store,
|
||||
array_map('intval', $data['bill_ids']),
|
||||
(string) $data['code'],
|
||||
(string) ($data['remark'] ?? ''),
|
||||
(string) ($data['scene'] ?? OnlinePaymentService::SCENE_MINI),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'id' => $payment->id,
|
||||
'payment_no' => $payment->payment_no,
|
||||
'amount' => $payment->amount,
|
||||
'pay_params' => $payParams,
|
||||
], '下单成功,请调起支付');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付结果:网关回调可能延迟/丢失,小程序完成支付后主动调用同步结账
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[GetRoute('/payment/online/{paymentNo}/query', authorize: true)]
|
||||
public function query(string $paymentNo, Request $request): JsonResponse
|
||||
{
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$payment = PaymentModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->where('payment_no', $paymentNo)
|
||||
->where('pay_type', PaymentModel::TYPE_ONLINE)
|
||||
->first();
|
||||
if ($payment === null) {
|
||||
throw new RepositoryException('支付记录不存在');
|
||||
}
|
||||
|
||||
$result = $this->onlinePayment->queryAndSettle($payment);
|
||||
$payment = $result['payment'];
|
||||
|
||||
return $this->success([
|
||||
'payment_no' => $payment->payment_no,
|
||||
'status' => $payment->status,
|
||||
'status_name' => PaymentModel::ONLINE_STATUS_NAMES[$payment->status] ?? '待支付',
|
||||
'paid_at' => $payment->paid_at,
|
||||
'trade_no' => $payment->trade_no,
|
||||
], $payment->status === PaymentModel::STATUS_APPROVED ? '支付成功' : '支付结果确认中');
|
||||
}
|
||||
|
||||
/**
|
||||
* 旺铺支付结果后台通知(公开路由,MD5 验签后幂等结账)
|
||||
*
|
||||
* 通知报文为明文表单(不涉及加解密),sign 之外非空数据元 ASCII 升序拼接 + 专用加签Key 做 MD5,
|
||||
* 验签通过即视为核心平台合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
|
||||
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
|
||||
*/
|
||||
#[PostRoute('/payment/notify', authorize: false)]
|
||||
public function notify(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$params = $this->wangpu->verifyNotify($request->all());
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('旺铺支付通知验签失败', ['error' => $e->getMessage()]);
|
||||
return $this->notifyAck('01', '报文验签失败');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->onlinePayment->settleByNotify($params);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('旺铺支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]);
|
||||
return $this->notifyAck('01', $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->notifyAck(WangpuPayService::NOTIFY_ACK_OK, '成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信支付结果后台通知(公开路由,验签 + AES-GCM 解密后幂等结账)
|
||||
*
|
||||
* 应答 {"code":"SUCCESS","message":"成功"} 视为通知成功,否则微信按阶梯间隔重推;
|
||||
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
|
||||
*/
|
||||
#[PostRoute('/payment/wechat-notify', authorize: false)]
|
||||
public function wechatNotify(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$params = $this->wxpay->decryptNotify($request);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('微信支付通知验签/解密失败', ['error' => $e->getMessage()]);
|
||||
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_FAIL, '报文验签解密失败');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->onlinePayment->settleByWechatNotify($params);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('微信支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]);
|
||||
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_FAIL, $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_OK, '成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信通知应答报文(微信约定格式)
|
||||
*/
|
||||
protected function wechatNotifyAck(string $code, string $msg): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'message' => mb_substr($msg, 0, 64),
|
||||
], $code === WechatPayService::NOTIFY_ACK_OK ? 200 : 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知应答报文(网关约定格式,timestamp:yyyyMMddHHmmssSSS)
|
||||
*/
|
||||
protected function notifyAck(string $code, string $msg): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'msg' => mb_substr($msg, 0, 100),
|
||||
'timestamp' => now()->format('YmdHisv'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,15 @@ namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Mini\MiniOrderRequest;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\CartModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Services\BillNumberService;
|
||||
use App\Services\ItemImageResolver;
|
||||
use App\Services\WeightEstimator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -29,16 +33,40 @@ class OrderController extends BaseMiniController
|
||||
#[PostRoute('/order', authorize: true)]
|
||||
public function store(MiniOrderRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
if ($store->level_id <= 0) {
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
// 截单时间校验:业务配置 services.order_time_start / order_time_end,均未配置时不限制
|
||||
$this->assertWithinOrderTimeWindow();
|
||||
|
||||
$level = $store->level_id > 0 ? $store->level : null;
|
||||
if ($level === null) {
|
||||
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
|
||||
}
|
||||
|
||||
// 回款周期校验:存在逾期未回款账单(未支付即拦截,含审核中)时禁止下单
|
||||
// 周期 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');
|
||||
$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);
|
||||
|
||||
$products = ProductModel::query()
|
||||
@@ -47,14 +75,9 @@ class OrderController extends BaseMiniController
|
||||
->get()
|
||||
->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';
|
||||
$totalAmount = '0';
|
||||
$totalWeight = '0';
|
||||
$now = now();
|
||||
$rows = [];
|
||||
foreach ($items as $row) {
|
||||
@@ -63,22 +86,16 @@ class OrderController extends BaseMiniController
|
||||
if ($product === null) {
|
||||
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
|
||||
}
|
||||
if (! isset($priceRows[$productId])) {
|
||||
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单');
|
||||
}
|
||||
|
||||
// 实际价(百分比计价行按成本价上浮换算,$products 已含 cost_price)
|
||||
$priceRow = $priceRows[$productId];
|
||||
$price = ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
$product->cost_price,
|
||||
);
|
||||
// 实际价(按门店等级上浮比例换算,$products 已含 cost_price)
|
||||
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
|
||||
$quantity = (string) $row['quantity'];
|
||||
$amount = bcmul($price, $quantity, 2);
|
||||
// 参考重量 = 订货量 × 规格折算(仅作参考,实际称重以采购录入为准)
|
||||
$weight = WeightEstimator::estimate((string) $product->spec, (string) $product->unit, $quantity);
|
||||
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
||||
$totalAmount = bcadd($totalAmount, $amount, 2);
|
||||
$totalWeight = bcadd($totalWeight, $weight, 3);
|
||||
|
||||
$rows[] = [
|
||||
'store_id' => $store->id,
|
||||
@@ -89,11 +106,12 @@ class OrderController extends BaseMiniController
|
||||
'product_spec' => $product->spec,
|
||||
'unit' => (string) $product->unit,
|
||||
'price' => $price,
|
||||
'price_unit' => $product->price_unit,
|
||||
'image_ids' => implode(',', (array) $product->image_ids),
|
||||
'content' => (string) $product->content,
|
||||
'shelf_life' => (int) $product->shelf_life,
|
||||
'quantity' => $quantity,
|
||||
'weight' => 0,
|
||||
'weight' => $weight,
|
||||
'amount' => $amount,
|
||||
'cost_price' => (string) $product->cost_price,
|
||||
'remark' => '',
|
||||
@@ -107,8 +125,7 @@ class OrderController extends BaseMiniController
|
||||
'store_id' => $store->id,
|
||||
'order_date' => $now->toDateString(),
|
||||
'total_quantity' => $totalQuantity,
|
||||
'total_weight' => 0,
|
||||
'product_amount' => $totalAmount,
|
||||
'total_weight' => $totalWeight,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => StoreOrderModel::STATUS_PENDING,
|
||||
'remark' => $remark,
|
||||
@@ -119,6 +136,12 @@ class OrderController extends BaseMiniController
|
||||
}
|
||||
StoreOrderItemModel::insert($rows);
|
||||
|
||||
// 下单成功后自动清空购物车中已下单的商品
|
||||
CartModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->delete();
|
||||
|
||||
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)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
$params = $request->validate([
|
||||
'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);
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', (int) $request->input('status'));
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$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')
|
||||
->paginate((int) $request->input('pageSize', 10))
|
||||
->toArray();
|
||||
->paginate((int) ($params['pageSize'] ?? 10));
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
// 按数据库方言选择周期分组表达式(生产 MySQL / 测试 SQLite)
|
||||
$driver = DB::connection()->getDriverName();
|
||||
@@ -199,8 +294,7 @@ class OrderController extends BaseMiniController
|
||||
#[GetRoute('/order/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function detail(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$order = StoreOrderModel::with('items')
|
||||
->where('store_id', $store->id)
|
||||
@@ -216,8 +310,7 @@ class OrderController extends BaseMiniController
|
||||
#[PutRoute('/order/{id}/cancel', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function cancel(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$order = StoreOrderModel::where('store_id', $store->id)->find($id);
|
||||
if ($order === null) {
|
||||
@@ -232,4 +325,44 @@ class OrderController extends BaseMiniController
|
||||
|
||||
return $this->success([], '订单已取消');
|
||||
}
|
||||
|
||||
/**
|
||||
* 截单时间校验:业务配置 services.order_time_start / order_time_end(HH:mm)
|
||||
* 均留空不限制;只配一端按单边限制;开始时间晚于截单时间表示跨天时段(如 20:00-次日06:00);
|
||||
* 格式非法的配置按未配置处理,避免误配置导致全天无法下单
|
||||
*/
|
||||
private function assertWithinOrderTimeWindow(): void
|
||||
{
|
||||
$parse = static function (mixed $value): ?int {
|
||||
$value = trim((string) $value);
|
||||
if (! preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
return (int) $matches[1] * 60 + (int) $matches[2];
|
||||
};
|
||||
|
||||
$startText = trim((string) site_config('services.order_time_start', ''));
|
||||
$endText = trim((string) site_config('services.order_time_end', ''));
|
||||
$start = $parse($startText);
|
||||
$end = $parse($endText);
|
||||
if ($start === null && $end === null) {
|
||||
return;
|
||||
}
|
||||
if ($start !== null && $start === $end) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = (int) now()->format('H') * 60 + (int) now()->format('i');
|
||||
$allowed = match (true) {
|
||||
// 跨天时段:当晚开始时间之后 或 次日截单时间之前
|
||||
$start !== null && $end !== null && $start > $end => $now >= $start || $now <= $end,
|
||||
$start !== null && $end !== null => $now >= $start && $now <= $end,
|
||||
$start !== null => $now >= $start,
|
||||
default => $now <= $end,
|
||||
};
|
||||
if (! $allowed) {
|
||||
$window = ($startText !== '' ? $startText : '00:00') . ' - ' . ($endText !== '' ? $endText : '24:00');
|
||||
throw new RepositoryException('当前不在下单时段内(下单时间 ' . $window . '),请在规定时间内下单');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,77 +3,39 @@
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Services\CartService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
|
||||
/**
|
||||
* 小程序商品(分类树 + 列表)
|
||||
*
|
||||
* 商品浏览仅需登录:未绑定门店/门店未设客户等级的用户也可查看商品,仅价格不可见(price=null);
|
||||
* 加购、下单仍由购物车/订单前置校验拦截,要求绑定门店并已设客户等级。
|
||||
* 小程序商品
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class ProductController extends BaseMiniController
|
||||
{
|
||||
/** 分类树(仅含上架商品的分类及其祖先,保证树结构完整) */
|
||||
#[GetRoute('/product/categories', authorize: true)]
|
||||
/** 分类树(全部启用分类,含暂无商品的分类) */
|
||||
#[GetRoute('/product/categories', authorize: false)]
|
||||
public function categories(): JsonResponse
|
||||
{
|
||||
$activeCategoryIds = ProductModel::query()
|
||||
->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));
|
||||
return $this->success(ProductCategoryModel::getTreeData(['*'], true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品列表:价格取当前门店客户等级价;
|
||||
* 未绑定门店/门店未设客户等级 → 仍可浏览,price 为 null(不可见价格,加购/下单另由前置校验拦截);
|
||||
* ?category_id=&keyword=&page=&pageSize=
|
||||
* 商品列表(登录门店附 cart_id/cart_quantity 供列表直接加减购物车;
|
||||
* data.cart 为购物车悬浮球汇总,未登录返回零值结构)
|
||||
*/
|
||||
#[GetRoute('/product/list', authorize: true)]
|
||||
#[GetRoute('/product/list', authorize: false)]
|
||||
public function products(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$levelId = $this->boundStore($user)?->level_id ?? 0;
|
||||
|
||||
$query = ProductModel::query()
|
||||
->where('status', ProductModel::STATUS_ON)
|
||||
->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);
|
||||
if ($categoryId > 0) {
|
||||
$query->where('category_id', $categoryId);
|
||||
@@ -81,24 +43,80 @@ class ProductController extends BaseMiniController
|
||||
$keyword = trim((string) $request->input('keyword', ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(static function ($q) use ($keyword) {
|
||||
$q->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('spec', 'like', '%' . $keyword . '%');
|
||||
$q->where('name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$pageSize = (int) $request->input('pageSize', 10);
|
||||
$data = $query->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
|
||||
// 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价;
|
||||
// 未绑定门店或未设等级价为 null——未加载 prices 时 ?? null 兜底)
|
||||
// unset prices 同时移除 price_type/percent,门店端无法反推成本;cost_price 已被 $hidden 过滤
|
||||
foreach ($data['data'] as &$row) {
|
||||
$row['price'] = $row['prices'][0]['actual_price'] ?? null;
|
||||
unset($row['prices']);
|
||||
// 先按分类树展示顺序(与小程序分类栏一致;不在启用分类树中的商品排最后),再按商品排序
|
||||
$categoryIds = ProductCategoryModel::getTreeOrderedIds(true);
|
||||
if ($categoryIds !== []) {
|
||||
$cases = [];
|
||||
foreach ($categoryIds as $position => $categoryId) {
|
||||
$cases[] = "WHEN {$categoryId} THEN {$position}";
|
||||
}
|
||||
$query->orderByRaw(
|
||||
'CASE category_id ' . implode(' ', $cases) . ' ELSE ' . count($categoryIds) . ' END'
|
||||
);
|
||||
}
|
||||
|
||||
$pageSize = (int) $request->input('pageSize', 10);
|
||||
$paginator = $query->orderBy('sort')
|
||||
->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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
public function info(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
return $this->success([
|
||||
'id' => $store->id,
|
||||
@@ -46,8 +45,7 @@ class StoreController extends BaseMiniController
|
||||
'address.max' => '地址最长 255 个字符',
|
||||
]);
|
||||
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$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;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\ItemImageResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -38,6 +39,8 @@ class StoreOrderController extends BaseController
|
||||
$query = StoreOrderModel::query()->with([
|
||||
'store:id,name,address,contact,phone',
|
||||
'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)
|
||||
->orderBy('order_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
@@ -64,17 +76,10 @@ class StoreOrderController extends BaseController
|
||||
// 明细封面图:从快照 image_ids 批量解析(不再关联商品档案表)
|
||||
foreach ($data['data'] as &$order) {
|
||||
app(ItemImageResolver::class)->resolve($order['items']);
|
||||
$this->appendBillPayState($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);
|
||||
}
|
||||
|
||||
@@ -85,6 +90,7 @@ class StoreOrderController extends BaseController
|
||||
$order = StoreOrderModel::with([
|
||||
'store:id,name,address,contact,phone',
|
||||
'items' => static fn ($query) => $query->with('supplier:id,name'),
|
||||
'bill',
|
||||
])->find($id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
@@ -95,14 +101,10 @@ class StoreOrderController extends BaseController
|
||||
|
||||
$data = $order->toArray();
|
||||
|
||||
// 明细首图 + 附加金额(与列表接口一致)
|
||||
// 明细首图
|
||||
app(ItemImageResolver::class)->resolve($data['items']);
|
||||
$boxAmount = site_config('services.box_amount');
|
||||
$trayAmount = site_config('services.tray_amount');
|
||||
$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);
|
||||
// 关联账单详情:补充支付进度
|
||||
$this->appendBillPayState($data);
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
@@ -114,10 +116,10 @@ class StoreOrderController extends BaseController
|
||||
public function status(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => 'required|integer|in:1,3,4,9',
|
||||
'status' => 'required|integer|in:1,9',
|
||||
], [
|
||||
'status.required' => '目标状态不能为空',
|
||||
'status.in' => '目标状态值不正确',
|
||||
'status.in' => '目标状态仅支持接单或取消订单',
|
||||
]);
|
||||
|
||||
$order = StoreOrderModel::find($id);
|
||||
@@ -149,13 +151,13 @@ class StoreOrderController extends BaseController
|
||||
$data = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer|exists:store_order,id',
|
||||
'status' => 'required|integer|in:1,3,4,9',
|
||||
'status' => 'required|integer|in:1,9',
|
||||
], [
|
||||
'ids.required' => '请选择要流转的订单',
|
||||
'ids.min' => '请选择要流转的订单',
|
||||
'ids.*.exists' => '订单不存在',
|
||||
'status.required' => '目标状态不能为空',
|
||||
'status.in' => '目标状态值不正确',
|
||||
'status.in' => '目标状态仅支持接单或取消订单',
|
||||
]);
|
||||
|
||||
$target = (int) $data['status'];
|
||||
@@ -206,63 +208,12 @@ class StoreOrderController extends BaseController
|
||||
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[]
|
||||
*/
|
||||
private function allowedTransitions(int $status): array
|
||||
@@ -272,29 +223,26 @@ class StoreOrderController extends BaseController
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_CANCELLED,
|
||||
],
|
||||
StoreOrderModel::STATUS_DELIVERING => [
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
StoreOrderModel::STATUS_COMPLETED,
|
||||
],
|
||||
StoreOrderModel::STATUS_DISTRIBUTION => [StoreOrderModel::STATUS_COMPLETED],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态流转后通知门店用户
|
||||
* 状态流转后通知门店
|
||||
*/
|
||||
private function notifyStore(StoreOrderModel $order): void
|
||||
{
|
||||
$userIds = UserModel::query()
|
||||
->where('store_id', $order->store_id)
|
||||
->where('status', UserModel::STATUS_NORMAL)
|
||||
->pluck('id');
|
||||
$store = StoreModel::query()
|
||||
->where('id', $order->store_id)
|
||||
->where('status', StoreModel::STATUS_NORMAL)
|
||||
->first();
|
||||
if ($store === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statusName = StoreOrderModel::STATUS_NAMES[$order->status] ?? (string) $order->status;
|
||||
foreach ($userIds as $userId) {
|
||||
NoticeModel::create([
|
||||
'user_id' => $userId,
|
||||
'store_id' => $store->id,
|
||||
'type' => NoticeModel::TYPE_ORDER,
|
||||
'title' => '订单状态更新',
|
||||
'content' => mb_substr("您的订单 {$order->order_no} 状态已更新为「{$statusName}」", 0, 500),
|
||||
@@ -302,5 +250,21 @@ class StoreOrderController extends BaseController
|
||||
'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\ProductModel;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 商品分类管理(多级分类:蔬菜/水果/其他)
|
||||
@@ -37,34 +34,17 @@ class ProductCategoryController extends BaseController
|
||||
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')]
|
||||
public function create(ProductCategoryFormRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$parentId = (int) $validated['parent_id'];
|
||||
if ($parentId > 0 && ! ProductCategoryModel::whereKey($parentId)->exists()) {
|
||||
throw new RepositoryException('父级分类不存在');
|
||||
}
|
||||
$this->assertParentIsTopLevel((int) $validated['parent_id']);
|
||||
ProductCategoryModel::create($validated);
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 编辑分类(防自引用成环) */
|
||||
/** 编辑分类(防自引用成环;分类最多二级) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, ProductCategoryFormRequest $request): JsonResponse
|
||||
{
|
||||
@@ -73,7 +53,12 @@ class ProductCategoryController extends BaseController
|
||||
throw new RepositoryException('分类不存在');
|
||||
}
|
||||
$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);
|
||||
return $this->success();
|
||||
}
|
||||
@@ -96,6 +81,23 @@ class ProductCategoryController extends BaseController
|
||||
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 指向自身或子孙分类形成环
|
||||
*/
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
namespace App\Http\Controllers\Product;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Exports\ProductExport;
|
||||
use App\Http\Requests\Product\BatchPriceRequest;
|
||||
use App\Http\Requests\Product\ProductFormRequest;
|
||||
use App\Imports\ProductImport;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
@@ -22,9 +23,10 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 商品档案管理
|
||||
* 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行)
|
||||
*/
|
||||
#[RequestAttribute('/product/goods', 'product.goods')]
|
||||
class ProductController extends BaseController
|
||||
@@ -33,12 +35,13 @@ class ProductController extends BaseController
|
||||
'name' => 'like',
|
||||
'category_id' => '=',
|
||||
'supplier_id' => '=',
|
||||
'market' => 'like',
|
||||
'status' => '=',
|
||||
];
|
||||
|
||||
protected array $quickSearchField = ['name', 'spec'];
|
||||
|
||||
/** A1 商品列表(含分类/供应商/各等级价格;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
||||
/** A1 商品列表(含分类/供应商;prices 为按启用等级上浮比例换算的展示价;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
@@ -46,12 +49,26 @@ class ProductController extends BaseController
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch(
|
||||
$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('id', 'desc')
|
||||
->paginate($pageSize);
|
||||
$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());
|
||||
}
|
||||
|
||||
@@ -70,32 +87,62 @@ class ProductController extends BaseController
|
||||
}
|
||||
|
||||
|
||||
/** 创建商品(事务内建商品 + 同步等级价格) */
|
||||
/** 创建商品 */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(ProductFormRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $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;
|
||||
});
|
||||
|
||||
$product = ProductModel::create($request->validated());
|
||||
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]+'])]
|
||||
public function update(int $id, ProductFormRequest $request): JsonResponse
|
||||
{
|
||||
@@ -103,34 +150,11 @@ class ProductController extends BaseController
|
||||
if (empty($product)) {
|
||||
throw new RepositoryException('商品不存在');
|
||||
}
|
||||
$validated = $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();
|
||||
}
|
||||
});
|
||||
|
||||
$product->update($request->validated());
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 删除商品(软删除,连带价格行一并删除) */
|
||||
/** 删除商品(软删除) */
|
||||
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
@@ -138,22 +162,35 @@ class ProductController extends BaseController
|
||||
if (empty($product)) {
|
||||
throw new RepositoryException('商品不存在');
|
||||
}
|
||||
DB::transaction(function () use ($product) {
|
||||
$product->prices()->delete();
|
||||
$product->delete();
|
||||
});
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 批量删除商品(软删除),复用 delete 权限点 */
|
||||
#[DeleteRoute('/batch', 'delete')]
|
||||
public function batchDelete(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer|distinct',
|
||||
], [
|
||||
'ids.required' => '请选择要删除的商品',
|
||||
'ids.min' => '请选择要删除的商品',
|
||||
'ids.*.integer' => '商品 ID 格式错误',
|
||||
'ids.*.distinct' => '存在重复的商品',
|
||||
]);
|
||||
ProductModel::whereIn('id', $data['ids'])->delete();
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
|
||||
* 列=全部启用等级,值=实际销售价(缺失为 null);行内含 cost_price 与每等级
|
||||
* price_type_{levelId} / percent_{levelId},供前端判断计价类型与联动重算
|
||||
* 列=全部启用等级,值=按等级上浮比例换算的售价(成本价未设置为 null);仅成本价可编辑
|
||||
*/
|
||||
#[GetRoute('/priceMatrix', 'query')]
|
||||
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) {
|
||||
$query->where('category_id', $categoryId);
|
||||
}
|
||||
@@ -168,13 +205,9 @@ class ProductController extends BaseController
|
||||
$page = max(1, (int) $request->input('page', 1));
|
||||
$products = $query->orderBy('sort')->orderBy('id')->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$levels = CustomerLevelModel::query()
|
||||
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
||||
->orderBy('sort')
|
||||
->get(['id', 'name']);
|
||||
$levels = $this->enabledLevels();
|
||||
|
||||
$rows = $products->getCollection()->map(function (ProductModel $product) use ($levels) {
|
||||
$priceMap = $product->prices->keyBy('level_id');
|
||||
$rows = $products->getCollection()->map(static function (ProductModel $product) use ($levels) {
|
||||
$row = [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
@@ -183,31 +216,27 @@ class ProductController extends BaseController
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
];
|
||||
foreach ($levels as $level) {
|
||||
$price = $priceMap[$level->id] ?? null;
|
||||
$row['price_' . $level->id] = $price
|
||||
? (float) ProductPriceModel::calcActualPrice(
|
||||
(int) $price->price_type,
|
||||
$price->price,
|
||||
$price->percent,
|
||||
$product->cost_price,
|
||||
)
|
||||
$row['price_' . $level->id] = $product->cost_price > 0
|
||||
? (float) CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||
: 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 $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(),
|
||||
'total' => $products->total(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 批量调价:三类更新行(成本价 / 固定价 / 成本百分比,可混合同一行),事务写入,
|
||||
* 写完后给受影响门店生成 Notice(type=price)
|
||||
* A2 批量调价:批量调整成本价(等级售价随之按上浮比例联动),事务写入,
|
||||
* 写完后给全部正常门店的用户生成 Notice(type=price)
|
||||
*/
|
||||
#[PutRoute('/batchPrice', 'batchPrice')]
|
||||
public function batchPrice(BatchPriceRequest $request): JsonResponse
|
||||
@@ -216,65 +245,30 @@ class ProductController extends BaseController
|
||||
|
||||
DB::transaction(function () use ($updates) {
|
||||
$productIds = [];
|
||||
$levelIds = [];
|
||||
foreach ($updates as $row) {
|
||||
$productId = (int) $row['product_id'];
|
||||
$productIds[$productId] = true;
|
||||
|
||||
// 分支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))
|
||||
->pluck('name')
|
||||
->implode('、');
|
||||
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
|
||||
|
||||
// 受影响门店:客户等级在受影响等级范围内的正常门店,通知其绑定的正常用户
|
||||
$userIds = UserModel::query()
|
||||
->where('status', UserModel::STATUS_NORMAL)
|
||||
->whereIn('store_id', function ($q) use ($percentLevelIds) {
|
||||
$q->select('id')
|
||||
->from('store')
|
||||
// 成本价变更影响所有等级的售价:通知全部正常门店
|
||||
$storeIds = StoreModel::query()
|
||||
->where('status', StoreModel::STATUS_NORMAL)
|
||||
->whereIn('level_id', $percentLevelIds);
|
||||
})
|
||||
->pluck('id');
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
foreach ($storeIds as $storeId) {
|
||||
NoticeModel::create([
|
||||
'user_id' => $userId,
|
||||
'store_id' => $storeId,
|
||||
'type' => NoticeModel::TYPE_PRICE,
|
||||
'title' => '商品价格变更',
|
||||
'content' => $content,
|
||||
'data' => [
|
||||
'product_ids' => array_keys($productIds),
|
||||
'level_ids' => array_keys($levelIds),
|
||||
],
|
||||
'is_read' => NoticeModel::UNREAD,
|
||||
]);
|
||||
@@ -298,4 +292,17 @@ class ProductController extends BaseController
|
||||
->toArray();
|
||||
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;
|
||||
|
||||
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\PurchaseRowUpdateRequest;
|
||||
use App\Http\Requests\Purchase\PurchaseStoreItemRequest;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseItemCheckModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Services\BillGenerateService;
|
||||
use App\Services\ItemImageResolver;
|
||||
use App\Services\PurchaseGenerateService;
|
||||
use App\Services\PurchaseItemService;
|
||||
use App\Services\WeightEstimator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
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 Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改)
|
||||
* 采购单无独立明细表,明细直接溯源门店订货明细(store_order_item.purchase_id)
|
||||
* 采购单管理
|
||||
*/
|
||||
#[RequestAttribute('/purchase/order', 'purchase.order')]
|
||||
class PurchaseOrderController extends BaseController
|
||||
@@ -41,7 +55,10 @@ class PurchaseOrderController extends BaseController
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch($params, PurchaseOrderModel::query()->with('operator:id,nickname'))
|
||||
$data = $this->buildSearch($params, PurchaseOrderModel::query()
|
||||
->with('operator:id,nickname')
|
||||
// 应付商品金额 = Σ 已生成门店账单的商品金额(实际口径;未生成账单为 null)
|
||||
->withSum('bills as bill_product_amount', 'product_amount'))
|
||||
->orderBy('purchase_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
@@ -93,6 +110,8 @@ class PurchaseOrderController extends BaseController
|
||||
$quantity = 0;
|
||||
// 采购总重量
|
||||
$weight = '0';
|
||||
// 采购总金额(Σ明细 amount,单价按 金额÷数量 加权)
|
||||
$amount = '0';
|
||||
// 门店明细
|
||||
$cellsMap = [];
|
||||
|
||||
@@ -101,6 +120,7 @@ class PurchaseOrderController extends BaseController
|
||||
// 累加总量
|
||||
$quantity += (int) $item->quantity;
|
||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||
$amount = bcadd($amount, (string) $item->amount, 2);
|
||||
|
||||
// 按门店合并
|
||||
if (!isset($cellsMap[$storeId])) {
|
||||
@@ -119,9 +139,11 @@ class PurchaseOrderController extends BaseController
|
||||
'unit' => $first->unit, // 单位
|
||||
'supplier_id' => (int) $first->supplier_id, // 供应商id
|
||||
'supplier' => $supplier, // 供应商
|
||||
'market' => (string) ($product?->market ?? ''), // 市场(取商品档案)
|
||||
'cost_price' => $first->cost_price, // 成本价
|
||||
'quantity' => $quantity,
|
||||
'weight' => (float) $weight,
|
||||
'amount' => $amount,
|
||||
'cells' => $cellsMap,
|
||||
'category_sort' => (int) ($product->category->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']]
|
||||
<=> [$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([
|
||||
'purchase' => $purchase->toArray(),
|
||||
'stores' => $stores->toArray(),
|
||||
@@ -138,29 +176,70 @@ class PurchaseOrderController extends BaseController
|
||||
unset($row['category_sort'], $row['product_sort']);
|
||||
return $row;
|
||||
}, $rows),
|
||||
'bills' => $bills,
|
||||
// 单品「已对账」标记(入库持久化,商品ID列表)
|
||||
'checked_product_ids' => PurchaseItemCheckModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->pluck('product_id')
|
||||
->map(static fn ($v) => (int) $v)
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** C4 修改采购单头信息(采购日期、状态、备注) */
|
||||
/** 修改采购单信息(采购日期、实际金额、备注) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchase_date' => 'nullable|date_format:Y-m-d',
|
||||
'status' => 'nullable|integer|in:' . PurchaseOrderModel::STATUS_PENDING . ',' . PurchaseOrderModel::STATUS_COMPLETED,
|
||||
'actual_amount' => 'required|numeric|min:0',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
], [
|
||||
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
|
||||
'status.in' => '采购单状态不正确',
|
||||
'actual_amount.required' => '实际金额不能为空',
|
||||
'actual_amount.numeric' => '实际金额格式错误',
|
||||
'actual_amount.min' => '实际金额不能小于0',
|
||||
'remark.max' => '备注超过最大长度',
|
||||
]);
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||
throw new RepositoryException('采购单已完成,不允许修改信息');
|
||||
}
|
||||
$purchase->update(array_filter($data, static fn ($v) => $v !== null));
|
||||
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,
|
||||
'remark' => (string) $item->remark,
|
||||
'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);
|
||||
@@ -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
|
||||
*/
|
||||
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
||||
@@ -270,12 +701,11 @@ class PurchaseOrderController extends BaseController
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||
throw new RepositoryException('采购单已完成,不允许修改明细');
|
||||
}
|
||||
$this->assertPurchaseEditable($purchase);
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
return DB::transaction(function () use ($purchase, $productId, $validated) {
|
||||
$items = StoreOrderItemModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->where('product_id', $productId)
|
||||
@@ -284,17 +714,64 @@ class PurchaseOrderController extends BaseController
|
||||
if ($items->isEmpty()) {
|
||||
throw new RepositoryException('该采购单下无此商品的订货明细');
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
$item->fill($validated)->save();
|
||||
|
||||
// 成本价是否变化(未变化时保留明细单价,避免覆盖单元格/单品的手工改价)
|
||||
$newCost = bcadd((string) $validated['cost_price'], '0', 2);
|
||||
$costChanged = bccomp($newCost, bcadd((string) $items->first()->cost_price, '0', 2), 2) !== 0;
|
||||
|
||||
// 成本变化 → 按门店等级上浮比例重算单价(一次性取回涉及门店的等级,避免逐行查询)
|
||||
$levelPercents = [];
|
||||
if ($costChanged) {
|
||||
$levelPercents = StoreModel::withTrashed()
|
||||
->with('level:id,percent')
|
||||
->whereIn('id', $items->pluck('store_id')->unique())
|
||||
->get(['id', 'level_id'])
|
||||
->mapWithKeys(static fn (StoreModel $store) => [
|
||||
$store->id => (float) ($store->level?->percent ?? 0),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
// 重算采购单成本
|
||||
$query = StoreOrderItemModel::query()->where('purchase_id', $purchase->id);
|
||||
$purchase->total_quantity = $query->sum('quantity');
|
||||
$purchase->estimate_amount = $query->sum(DB::raw('quantity * cost_price'));
|
||||
$purchase->save();
|
||||
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();
|
||||
}
|
||||
|
||||
return $this->success(['count' => $items->count()], '已同步 ' . $items->count() . ' 条订货明细');
|
||||
// 同步保存到商品档案(软删除商品跳过,明细照常更新)
|
||||
$product = ProductModel::find($productId);
|
||||
$productSynced = $product !== null;
|
||||
if ($productSynced) {
|
||||
$product->update([
|
||||
'name' => $validated['product_name'],
|
||||
'supplier_id' => $validated['supplier_id'],
|
||||
'spec' => $validated['product_spec'],
|
||||
'unit' => $validated['unit'],
|
||||
'cost_price' => $validated['cost_price'],
|
||||
]);
|
||||
}
|
||||
|
||||
$service = app(PurchaseItemService::class);
|
||||
// 成本变化会引起明细金额变动,涉及订单需逐一重算
|
||||
if ($costChanged) {
|
||||
foreach ($items->pluck('order_id')->unique() as $orderId) {
|
||||
$service->recalcOrder((int) $orderId);
|
||||
}
|
||||
}
|
||||
$service->recalcPurchase($purchase->id);
|
||||
|
||||
$message = '已同步 ' . $items->count() . ' 条订货明细'
|
||||
. ($productSynced ? '与商品档案' : ';商品已删除,档案未同步')
|
||||
. ($costChanged ? ';门店单价已按等级上浮比例重算' : '');
|
||||
|
||||
return $this->success(['count' => $items->count()], $message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,32 +792,128 @@ class PurchaseOrderController extends BaseController
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||
throw new RepositoryException('采购单已完成,不允许修改明细');
|
||||
}
|
||||
$this->assertPurchaseEditable($purchase);
|
||||
|
||||
$item->quantity = $validated['quantity'];
|
||||
$item->price = $validated['price'];
|
||||
if (array_key_exists('weight', $validated)) {
|
||||
$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();
|
||||
|
||||
// 重算订单金额
|
||||
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
|
||||
$order->product_amount = $order->items()->sum('amount');
|
||||
$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();
|
||||
$service = app(PurchaseItemService::class);
|
||||
$service->recalcOrder((int) $item->order_id);
|
||||
$service->recalcPurchase($purchase->id);
|
||||
|
||||
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.item,authorize: 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());
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
'name' => ['required', 'string', 'max:50', $unique],
|
||||
'percent' => 'nullable|numeric|min:0|max:999.99',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
||||
@@ -35,6 +36,9 @@ class CustomerLevelFormRequest extends BaseFormRequest
|
||||
'name.required' => '等级名称不能为空',
|
||||
'name.max' => '等级名称最长 50 个字符',
|
||||
'name.unique' => '等级名称已存在',
|
||||
'percent.numeric' => '价格上浮比例必须为数字',
|
||||
'percent.min' => '价格上浮比例不能小于 0',
|
||||
'percent.max' => '价格上浮比例不能超过 999.99',
|
||||
'status.in' => '状态值不正确',
|
||||
'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;
|
||||
|
||||
/**
|
||||
* 通知 创建 验证(user_id 留空 = 全员广播)
|
||||
* 通知 创建 验证(store_id 留空 = 全员广播)
|
||||
*/
|
||||
class NoticeFormRequest extends BaseFormRequest
|
||||
{
|
||||
@@ -13,13 +13,13 @@ class NoticeFormRequest extends BaseFormRequest
|
||||
|
||||
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
|
||||
{
|
||||
return [
|
||||
'user_id' => 'required|integer|min:0',
|
||||
'store_id' => 'required|integer|min:0',
|
||||
'type' => 'required|string|in:order,price,system',
|
||||
'title' => 'required|string|max:100',
|
||||
'content' => 'nullable|string|max:500',
|
||||
|
||||
@@ -6,7 +6,7 @@ use Illuminate\Validation\Rule;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 门店 创建/编辑 验证
|
||||
* 门店 创建/编辑 验证(登录账号唯一;密码创建必填,编辑留空不修改)
|
||||
*/
|
||||
class StoreFormRequest extends BaseFormRequest
|
||||
{
|
||||
@@ -14,8 +14,19 @@ class StoreFormRequest extends BaseFormRequest
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$id = (int) $this->route('id', 0);
|
||||
|
||||
return [
|
||||
'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',
|
||||
'contact' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
@@ -31,6 +42,14 @@ class StoreFormRequest extends BaseFormRequest
|
||||
return [
|
||||
'name.required' => '门店名称不能为空',
|
||||
'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.exists' => '客户等级不存在',
|
||||
'payment_cycle_days.integer' => '回款周期必须为整数',
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use App\Models\ProductPriceModel;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 批量调价 验证(A2 价格矩阵编辑提交)
|
||||
*
|
||||
* updates 每行支持三类更新(可混合同一行):
|
||||
* 等级售价 = 成本价 × (100 + 等级上浮比例) / 100,矩阵仅支持批量调整成本价:
|
||||
* 成本行 {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
|
||||
{
|
||||
@@ -24,54 +19,10 @@ class BatchPriceRequest extends BaseFormRequest
|
||||
return [
|
||||
'updates' => 'required|array|min:1',
|
||||
'updates.*.product_id' => 'required|integer|exists:product,id',
|
||||
'updates.*.cost_price' => 'nullable|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',
|
||||
'updates.*.cost_price' => 'required|numeric|min:0|max:99999999',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 行级交叉校验(在 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
|
||||
{
|
||||
return [
|
||||
@@ -79,15 +30,9 @@ class BatchPriceRequest extends BaseFormRequest
|
||||
'updates.min' => '请至少提交一条价格调整',
|
||||
'updates.*.product_id.required' => '调价行缺少商品',
|
||||
'updates.*.product_id.exists' => '商品不存在',
|
||||
'updates.*.cost_price.required' => '调价行缺少成本价',
|
||||
'updates.*.cost_price.numeric' => '成本价必须为数字',
|
||||
'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;
|
||||
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品分类 创建/编辑 验证
|
||||
@@ -24,7 +22,6 @@ class ProductCategoryFormRequest extends BaseFormRequest
|
||||
'name' => 'required|string|max:50',
|
||||
'parent_id' => 'required|integer|min:0',
|
||||
'sort' => 'nullable|integer',
|
||||
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
];
|
||||
}
|
||||
@@ -36,7 +33,6 @@ class ProductCategoryFormRequest extends BaseFormRequest
|
||||
'name.max' => '分类名称最长 50 个字符',
|
||||
'parent_id.min' => '父级分类ID不正确',
|
||||
'status.in' => '状态值不正确',
|
||||
'icon_id.exists' => '请重新上传图片'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
@@ -12,7 +11,7 @@ use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品档案 创建/编辑 验证(含多等级价格 prices 数组)
|
||||
* 商品档案 创建/编辑 验证(售价按客户等级上浮比例换算,不再维护等级价格行)
|
||||
*/
|
||||
class ProductFormRequest extends BaseFormRequest
|
||||
{
|
||||
@@ -23,9 +22,11 @@ class ProductFormRequest extends BaseFormRequest
|
||||
return [
|
||||
'category_id' => ['required','integer', new Exists(ProductCategoryModel::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',
|
||||
'spec' => 'nullable|string|max:100',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'price_unit' => 'nullable|string|max:20',
|
||||
'image_ids' => 'nullable|array|max:255',
|
||||
'image_ids.*' => ['integer', new Exists(SysFileModel::class, 'id')],
|
||||
'content' => 'nullable|string',
|
||||
@@ -35,28 +36,20 @@ class ProductFormRequest extends BaseFormRequest
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
'cost_price' => 'nullable|numeric|min:0|max:99999999',
|
||||
'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)
|
||||
*/
|
||||
public function after(): Closure
|
||||
{
|
||||
return function (Validator $validator): void {
|
||||
$data = (array) $validator->getData();
|
||||
foreach ((array) ($data['prices'] ?? []) as $index => $row) {
|
||||
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED);
|
||||
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT
|
||||
&& (! array_key_exists('percent', (array) $row) || $row['percent'] === null || $row['percent'] === '')) {
|
||||
$validator->errors()->add("prices.{$index}.percent", '按成本百分比计价时必须填写上浮百分点');
|
||||
}
|
||||
$categoryId = (int) ($data['category_id'] ?? 0);
|
||||
if ($categoryId > 0 && ProductCategoryModel::where('parent_id', $categoryId)->exists()) {
|
||||
$validator->errors()->add('category_id', '该分类下存在子分类,请选择末级分类');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -70,17 +63,8 @@ class ProductFormRequest extends BaseFormRequest
|
||||
'category_id.exists' => '商品分类不存在',
|
||||
'supplier_id.exists' => '供应商不存在',
|
||||
'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.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' => '结束日期不能早于开始日期',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UserRegisterRequest extends FormRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => 'required|min:4|alphaDash',
|
||||
'password' => 'required|min:4|alphaDash',
|
||||
'rePassword' => 'required|min:4|same:password',
|
||||
'email' => 'required|email',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UserUpdateInfoRequest extends FormRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => 'required|min:4|max:20',
|
||||
'nickname' => 'required|min:4|max:20',
|
||||
'gender' => 'required',
|
||||
'email' => 'required|email',
|
||||
'avatar_id' => 'required|integer',
|
||||
'mobile' => 'required|regex:/^1[34578]\d{9}$/',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Exports\ProductExport;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
|
||||
/**
|
||||
* 商品档案 Excel 导入:整表校验,任何一行有错则全部不导入(返回全部错误行号+原因)。
|
||||
* 规则:品名必填;分类按名称/「父分类/子分类」路径匹配末级分类;供应商按名称匹配、不存在自动创建;
|
||||
* 导入一律新增商品,不做匹配更新。
|
||||
*/
|
||||
class ProductImport implements ToCollection
|
||||
{
|
||||
/** 单次导入数据行上限(防误传超大文件) */
|
||||
private const int MAX_ROWS = 1000;
|
||||
|
||||
/** @var array<int, array{row: int, message: string}> 校验错误行(Excel 行号,1 起) */
|
||||
public array $errors = [];
|
||||
|
||||
/** 成功写入的商品数 */
|
||||
public int $created = 0;
|
||||
|
||||
/** @var array<int, string> 本次自动创建的供应商名称 */
|
||||
public array $suppliersCreated = [];
|
||||
|
||||
/**
|
||||
* maatwebsite 入口:读取首个工作表全部行(第 1 行为列头)
|
||||
*
|
||||
* @param Collection<int, Collection<int, mixed>> $rows
|
||||
*/
|
||||
public function collection(Collection $rows): void
|
||||
{
|
||||
$header = $rows->first();
|
||||
if (! $this->isValidHeader($header)) {
|
||||
$this->errors[] = ['row' => 1, 'message' => '列头与导入模板不一致,请下载最新模板后重试'];
|
||||
return;
|
||||
}
|
||||
|
||||
$dataRows = $rows->slice(1)->values();
|
||||
if ($dataRows->count() > self::MAX_ROWS) {
|
||||
$this->errors[] = ['row' => 2, 'message' => '单次最多导入 ' . self::MAX_ROWS . ' 行,当前 ' . $dataRows->count() . ' 行,请拆分后导入'];
|
||||
return;
|
||||
}
|
||||
|
||||
$categories = ProductCategoryModel::all();
|
||||
$leafIds = $this->leafCategoryIds($categories);
|
||||
|
||||
$parsed = [];
|
||||
foreach ($dataRows as $index => $row) {
|
||||
$excelRow = $index + 2; // Excel 行号(1 起,含列头行)
|
||||
$cells = array_map(static fn ($cell) => is_string($cell) ? trim($cell) : $cell, $row->toArray());
|
||||
|
||||
// 整行空白视为空行跳过
|
||||
if (implode('', array_map(static fn ($cell) => (string) $cell, $cells)) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->parseRow($cells, $categories, $leafIds);
|
||||
if ($result['errors'] !== []) {
|
||||
foreach ($result['errors'] as $message) {
|
||||
$this->errors[] = ['row' => $excelRow, 'message' => $message];
|
||||
}
|
||||
} else {
|
||||
$parsed[] = $result['data'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->errors !== []) {
|
||||
return; // 整表校验:有错一行不写
|
||||
}
|
||||
if ($parsed === []) {
|
||||
$this->errors[] = ['row' => 2, 'message' => '表格中没有可导入的数据行'];
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writeRows($parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并校验一行数据;全部合法时返回写入字段
|
||||
*
|
||||
* @param array<int, mixed> $cells
|
||||
* @param Collection<int, ProductCategoryModel> $categories
|
||||
* @param array<int, true> $leafIds
|
||||
* @return array{data: array<string, mixed>, errors: array<int, string>}
|
||||
*/
|
||||
private function parseRow(array $cells, Collection $categories, array $leafIds): array
|
||||
{
|
||||
[$name, $categoryName, $supplierName, $market, $spec, $unit, $costPrice, $sort, $stock, $shelfLife, $statusText, $remark] =
|
||||
array_pad(array_slice($cells, 0, 12), 12, null);
|
||||
|
||||
$errors = [];
|
||||
|
||||
$name = (string) $name;
|
||||
if ($name === '') {
|
||||
$errors[] = '品名不能为空';
|
||||
} elseif (mb_strlen($name) > 100) {
|
||||
$errors[] = '品名最长 100 个字符';
|
||||
}
|
||||
|
||||
$categoryId = 0;
|
||||
$categoryName = (string) $categoryName;
|
||||
if ($categoryName === '') {
|
||||
$errors[] = '分类不能为空';
|
||||
} else {
|
||||
$categoryId = $this->resolveCategoryId($categoryName, $categories, $leafIds);
|
||||
if ($categoryId === 0) {
|
||||
$errors[] = '分类「' . $categoryName . '」不存在或不是末级分类(多个同名末级分类时请使用「父分类/子分类」格式)';
|
||||
}
|
||||
}
|
||||
|
||||
$supplierName = (string) $supplierName;
|
||||
if (mb_strlen($supplierName) > 100) {
|
||||
$errors[] = '供应商名称最长 100 个字符';
|
||||
}
|
||||
|
||||
$market = (string) $market;
|
||||
if (mb_strlen($market) > 50) {
|
||||
$errors[] = '市场最长 50 个字符';
|
||||
}
|
||||
|
||||
$spec = (string) $spec;
|
||||
if (mb_strlen($spec) > 100) {
|
||||
$errors[] = '规格/包规最长 100 个字符';
|
||||
}
|
||||
|
||||
$unit = (string) $unit === '' ? '斤' : (string) $unit;
|
||||
if (mb_strlen($unit) > 20) {
|
||||
$errors[] = '单位最长 20 个字符';
|
||||
}
|
||||
|
||||
$cost = $this->parseDecimal($costPrice, 0, 99999999);
|
||||
if ($cost === null) {
|
||||
$errors[] = '成本价必须为不小于 0 的数字';
|
||||
}
|
||||
|
||||
$sort = $this->parseInteger($sort);
|
||||
if ($sort === null) {
|
||||
$errors[] = '排序必须为不小于 0 的整数';
|
||||
}
|
||||
|
||||
$stock = $this->parseInteger($stock);
|
||||
if ($stock === null) {
|
||||
$errors[] = '库存必须为不小于 0 的整数';
|
||||
}
|
||||
|
||||
$shelfLife = $this->parseInteger($shelfLife);
|
||||
if ($shelfLife === null) {
|
||||
$errors[] = '保质期必须为不小于 0 的整数';
|
||||
}
|
||||
|
||||
$status = $this->parseStatus($statusText);
|
||||
if ($status === null) {
|
||||
$errors[] = '状态只能填「上架」或「下架」';
|
||||
}
|
||||
|
||||
$remark = (string) $remark;
|
||||
if (mb_strlen($remark) > 255) {
|
||||
$errors[] = '备注最长 255 个字符';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['data' => [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => [
|
||||
'category_id' => $categoryId,
|
||||
'supplier_id' => 0, // 写入阶段按 supplier_name 解析(匹配或自动创建)
|
||||
'supplier_name' => $supplierName, // 写入阶段解析,不入 product 表
|
||||
'market' => $market,
|
||||
'name' => $name,
|
||||
'spec' => $spec,
|
||||
'unit' => $unit,
|
||||
'image_ids' => '',
|
||||
'content' => '',
|
||||
'sort' => $sort,
|
||||
'shelf_life' => $shelfLife,
|
||||
'stock' => $stock,
|
||||
'status' => $status,
|
||||
'cost_price' => $cost,
|
||||
'remark' => $remark,
|
||||
],
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务写入:自动创建缺失供应商,批量插入商品
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $parsed
|
||||
*/
|
||||
private function writeRows(array $parsed): void
|
||||
{
|
||||
DB::transaction(function () use ($parsed) {
|
||||
// 供应商按名称匹配,不存在自动创建(supplier_id=0 且 supplier_name 非空 = 待创建)
|
||||
$supplierIds = SupplierModel::pluck('id', 'name');
|
||||
foreach (collect($parsed)->pluck('supplier_name')->filter()->unique() as $name) {
|
||||
if (! isset($supplierIds[$name])) {
|
||||
$supplier = SupplierModel::create(['name' => $name]);
|
||||
$supplierIds[$name] = $supplier->id;
|
||||
$this->suppliersCreated[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$payload = array_map(static function (array $row) use ($supplierIds, $now) {
|
||||
if ($row['supplier_name'] !== '') {
|
||||
$row['supplier_id'] = (int) $supplierIds[$row['supplier_name']];
|
||||
}
|
||||
unset($row['supplier_name']);
|
||||
$row['created_at'] = $now;
|
||||
$row['updated_at'] = $now;
|
||||
return $row;
|
||||
}, $parsed);
|
||||
|
||||
ProductModel::insert($payload);
|
||||
$this->created = count($payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类名称/路径 → 末级分类ID(0 = 解析失败)
|
||||
* 支持「父分类/子分类」路径逐级匹配;单名称时要求末级分类唯一
|
||||
*
|
||||
* @param Collection<int, ProductCategoryModel> $categories
|
||||
* @param array<int, true> $leafIds
|
||||
*/
|
||||
private function resolveCategoryId(string $name, Collection $categories, array $leafIds): int
|
||||
{
|
||||
// 需 u 修饰符:多字节分隔符(/)按字节解析会切碎中文
|
||||
$segments = array_values(array_filter(array_map(
|
||||
static fn ($segment) => trim($segment),
|
||||
preg_split('#[//\\\\]#u', $name) ?: []
|
||||
), static fn ($segment) => $segment !== ''));
|
||||
|
||||
if ($segments === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (count($segments) > 1) {
|
||||
// 路径逐级匹配
|
||||
$parentId = 0;
|
||||
$matched = null;
|
||||
foreach ($segments as $segment) {
|
||||
$matched = $categories->first(
|
||||
static fn (ProductCategoryModel $category) => $category->name === $segment
|
||||
&& (int) $category->parent_id === $parentId
|
||||
);
|
||||
if ($matched === null) {
|
||||
return 0;
|
||||
}
|
||||
$parentId = (int) $matched->id;
|
||||
}
|
||||
return isset($leafIds[(int) $matched->id]) ? (int) $matched->id : 0;
|
||||
}
|
||||
|
||||
// 单名称:仅在末级分类中唯一时命中
|
||||
$leafMatches = $categories->filter(
|
||||
static fn (ProductCategoryModel $category) => $category->name === $segments[0]
|
||||
&& isset($leafIds[(int) $category->id])
|
||||
);
|
||||
if ($leafMatches->count() !== 1) {
|
||||
return 0;
|
||||
}
|
||||
return (int) $leafMatches->first()->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 末级分类ID集合(无任何子分类)
|
||||
*
|
||||
* @param Collection<int, ProductCategoryModel> $categories
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private function leafCategoryIds(Collection $categories): array
|
||||
{
|
||||
$parentIds = [];
|
||||
foreach ($categories as $category) {
|
||||
if ((int) $category->parent_id > 0) {
|
||||
$parentIds[(int) $category->parent_id] = true;
|
||||
}
|
||||
}
|
||||
$leafIds = [];
|
||||
foreach ($categories as $category) {
|
||||
if (! isset($parentIds[(int) $category->id])) {
|
||||
$leafIds[(int) $category->id] = true;
|
||||
}
|
||||
}
|
||||
return $leafIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列头校验:与导出/模板列头完全一致(允许尾部多余空单元格)
|
||||
*/
|
||||
private function isValidHeader(?Collection $header): bool
|
||||
{
|
||||
if ($header === null) {
|
||||
return false;
|
||||
}
|
||||
$cells = array_map(static fn ($cell) => trim((string) $cell), $header->toArray());
|
||||
foreach (ProductExport::HEADERS as $index => $expected) {
|
||||
if (($cells[$index] ?? '') !== $expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析十进制数字(空串取默认值;越界/非数字返回 null)
|
||||
*/
|
||||
private function parseDecimal(mixed $value, float $min, float $max): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
$value = 0;
|
||||
}
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
$number = (float) $value;
|
||||
if ($number < $min || $number > $max) {
|
||||
return null;
|
||||
}
|
||||
return number_format($number, 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析非负整数(空串取 0;非整数/负数返回 null)
|
||||
*/
|
||||
private function parseInteger(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
$number = (float) $value;
|
||||
if ($number < 0 || $number !== (float) (int) $number) {
|
||||
return null;
|
||||
}
|
||||
return (int) $number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析状态(空串默认上架;接受 上架/下架/1/0)
|
||||
*/
|
||||
private function parseStatus(mixed $value): ?int
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
return match ($text) {
|
||||
'', '上架', '1' => ProductModel::STATUS_ON,
|
||||
'下架', '0' => ProductModel::STATUS_OFF,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 小程序购物车模型(门店订货车:按用户归属,同商品唯一行、加购合并数量)
|
||||
* 小程序购物车模型(门店订货车:按门店归属,同商品唯一行、加购合并数量)
|
||||
*/
|
||||
class CartModel extends Model
|
||||
{
|
||||
@@ -17,23 +17,23 @@ class CartModel extends Model
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'store_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 客户等级模型(同一商品按客户等级定价)
|
||||
* 客户等级模型(同一商品按等级上浮比例定价:售价 = 成本价 × (100 + percent) / 100)
|
||||
*/
|
||||
class CustomerLevelModel extends Model
|
||||
{
|
||||
@@ -25,12 +25,14 @@ class CustomerLevelModel extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'percent',
|
||||
'sort',
|
||||
'status',
|
||||
'icon_id'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'percent' => 'decimal:2',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
@@ -65,10 +67,17 @@ class CustomerLevelModel extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 该等级下的商品价格
|
||||
* 按等级上浮比例计算售价(统一换算入口,金额走 bcmath 保证两位小数精度)
|
||||
*
|
||||
* 售价 = 成本价 × (100 + percent) / 100(四舍五入保留两位)。
|
||||
*
|
||||
* @param string|int|float $costPrice 商品成本价(decimal cast 字符串)
|
||||
* @param string|int|float $percent 价格上浮比例(30 = 上浮 30%)
|
||||
* @return string 两位小数字符串,如 '13.05'
|
||||
*/
|
||||
public function prices(): HasMany
|
||||
public static function calcLevelPrice(string|int|float $costPrice, string|int|float $percent): string
|
||||
{
|
||||
return $this->hasMany(ProductPriceModel::class, 'level_id', 'id');
|
||||
$multiplier = bcadd('100', (string) $percent, 4);
|
||||
return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 小程序首页特价推荐商品
|
||||
*/
|
||||
class HomeSpecialModel extends Model
|
||||
{
|
||||
/** 状态:停用 */
|
||||
public const int STATUS_DISABLED = 0;
|
||||
/** 状态:正常 */
|
||||
public const int STATUS_NORMAL = 1;
|
||||
|
||||
protected $table = 'mini_home_special';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'sort',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_id' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联商品
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id');
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 通知模型(小程序端消息:订单 / 价格变更 / 系统;user_id=0 为全员广播)
|
||||
* 通知模型(小程序端消息:订单 / 价格变更 / 系统;store_id=0 为全员广播)
|
||||
*/
|
||||
class NoticeModel extends Model
|
||||
{
|
||||
@@ -22,14 +22,14 @@ class NoticeModel extends Model
|
||||
/** 已读 */
|
||||
public const READ = 1;
|
||||
|
||||
/** 全员广播时的 user_id 约定值 */
|
||||
public const BROADCAST_USER_ID = 0;
|
||||
/** 全员广播时的 store_id 约定值 */
|
||||
public const BROADCAST_STORE_ID = 0;
|
||||
|
||||
protected $table = 'notice';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'store_id',
|
||||
'type',
|
||||
'title',
|
||||
'content',
|
||||
@@ -41,15 +41,15 @@ class NoticeModel extends Model
|
||||
protected $casts = [
|
||||
'data' => 'array',
|
||||
'is_read' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
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',
|
||||
'name',
|
||||
'sort',
|
||||
'status',
|
||||
'icon_id'
|
||||
'status'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parent_id' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'icon_id' => 'integer',
|
||||
'created_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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按树展示顺序(sort/id 深度优先)获取分类 ID 列表,供商品列表等按分类顺序排序
|
||||
*
|
||||
* @param bool $onlyEnabled 是否仅包含启用分类
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public static function getTreeOrderedIds(bool $onlyEnabled = false): array
|
||||
{
|
||||
return static::flattenTreeIds(static::getTreeData(['id', 'parent_id'], $onlyEnabled));
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度优先展开分类树为有序 ID 列表
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $tree
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private static function flattenTreeIds(array $tree): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($tree as $node) {
|
||||
$ids[] = (int) $node['id'];
|
||||
if (!empty($node['children'])) {
|
||||
$ids = [...$ids, ...static::flattenTreeIds($node['children'])];
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平分类列表组装为树
|
||||
*
|
||||
|
||||
@@ -6,12 +6,11 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品档案模型(品名/规格包规/供应商/等级/多等级价格体系)
|
||||
* 商品档案模型(品名/规格包规/供应商/成本价;售价按客户等级上浮比例换算)
|
||||
*/
|
||||
class ProductModel extends Model
|
||||
{
|
||||
@@ -28,9 +27,11 @@ class ProductModel extends Model
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'supplier_id',
|
||||
'market',
|
||||
'name',
|
||||
'spec',
|
||||
'unit',
|
||||
'price_unit',
|
||||
'image_ids',
|
||||
'content',
|
||||
'sort',
|
||||
@@ -67,7 +68,7 @@ class ProductModel extends Model
|
||||
public function imageIds(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn ($value) => explode(',', $value),
|
||||
get: fn ($value) => $value ? explode(',', $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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 多等级价格(同一商品按客户等级定价)
|
||||
*/
|
||||
public function prices(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductPriceModel::class, 'product_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 商品价格模型(同一商品按客户等级定价,联合键 product_id + level_id)
|
||||
*
|
||||
* 计价类型:固定价(price 即实际单价)或成本百分比(实际单价 = 成本价 × (100 + percent) / 100)
|
||||
*/
|
||||
class ProductPriceModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 计价类型:固定价 */
|
||||
public const int PRICE_TYPE_FIXED = 0;
|
||||
/** 计价类型:成本百分比(按成本价上浮 percent 百分点) */
|
||||
public const int PRICE_TYPE_PERCENT = 1;
|
||||
|
||||
protected $table = 'product_price';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'level_id',
|
||||
'price',
|
||||
'price_type',
|
||||
'percent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_id' => 'integer',
|
||||
'level_id' => 'integer',
|
||||
'price' => 'decimal:2',
|
||||
'price_type' => 'integer',
|
||||
'percent' => 'decimal:2',
|
||||
];
|
||||
|
||||
/** 序列化时附带实际销售价(后台列表/小程序列表直接展示) */
|
||||
protected $appends = ['actual_price'];
|
||||
|
||||
/**
|
||||
* 所属商品
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 所属客户等级
|
||||
*/
|
||||
public function level(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerLevelModel::class, 'level_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商品 + 等级筛选价格
|
||||
*/
|
||||
public function scopeForProductLevel($query, int $productId, int $levelId)
|
||||
{
|
||||
return $query->where('product_id', $productId)->where('level_id', $levelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算实际销售价(统一换算入口,金额走 bcmath 保证两位小数精度)
|
||||
*
|
||||
* 固定价返回 price 原值;成本百分比返回 cost × (100 + percent) / 100(四舍五入保留两位)。
|
||||
*
|
||||
* @param string|int|float $price 固定价(decimal cast 后为 '5.50' 形式字符串)
|
||||
* @param string|int|float $percent 成本上浮百分点(30 = 上浮 30%)
|
||||
* @param string|int|float $costPrice 商品成本价(decimal cast 字符串)
|
||||
* @return string 两位小数字符串,如 '13.05'
|
||||
*/
|
||||
public static function calcActualPrice(
|
||||
int $priceType,
|
||||
string|int|float $price,
|
||||
string|int|float $percent,
|
||||
string|int|float $costPrice,
|
||||
): string {
|
||||
if ($priceType === self::PRICE_TYPE_PERCENT) {
|
||||
$multiplier = bcadd('100', (string) $percent, 4);
|
||||
return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2);
|
||||
}
|
||||
// 固定价:归一化为两位小数字符串
|
||||
return bcadd((string) $price, '0', 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际销售价访问器(供 toArray 输出 actual_price)
|
||||
*
|
||||
* 依赖 product 关系取成本价;prices 经商品 eager load 加载时逆向关系自动填充,无 N+1。
|
||||
* 注意:单独序列化本模型且未加载 product 关系时会触发一次查询,成本价缺失按 0 兜底。
|
||||
*/
|
||||
protected function getActualPriceAttribute(): string
|
||||
{
|
||||
return self::calcActualPrice(
|
||||
(int) $this->price_type,
|
||||
(string) $this->price,
|
||||
(string) $this->percent,
|
||||
(string) ($this->product?->cost_price ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
|
||||
/**
|
||||
* 采购单单品对账标记模型(商品明细「已对账」勾选,入库持久化;存在记录=已标记)
|
||||
*/
|
||||
class PurchaseItemCheckModel extends Model
|
||||
{
|
||||
protected $table = 'purchase_item_check';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'purchase_id',
|
||||
'product_id',
|
||||
'operator_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'purchase_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'operator_id' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联采购单
|
||||
*/
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记人(后台系统用户)
|
||||
*/
|
||||
public function operator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ class PurchaseOrderModel extends Model
|
||||
'estimate_amount' => 'decimal:2',
|
||||
'actual_amount' => 'decimal:2',
|
||||
'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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 本采购单生成的门店账单
|
||||
*/
|
||||
public function bills(): HasMany
|
||||
{
|
||||
return $this->hasMany(BillModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -3,17 +3,19 @@
|
||||
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 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;
|
||||
@@ -26,19 +28,33 @@ class StoreModel extends Model
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'username',
|
||||
'password',
|
||||
'avatar',
|
||||
'last_login_at',
|
||||
'level_id',
|
||||
'contact',
|
||||
'phone',
|
||||
'address',
|
||||
'payment_cycle_days',
|
||||
'openid',
|
||||
'mp_openid',
|
||||
'status',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'level_id' => 'integer',
|
||||
'payment_cycle_days' => 'integer',
|
||||
'total_purchase_amount' => 'decimal:2',
|
||||
'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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class StoreOrderItemModel extends Model
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'purchase_id',
|
||||
'bill_id',
|
||||
'store_id',
|
||||
'product_id',
|
||||
'category_id',
|
||||
@@ -29,6 +30,7 @@ class StoreOrderItemModel extends Model
|
||||
'product_spec',
|
||||
'unit',
|
||||
'price',
|
||||
'price_unit',
|
||||
'image_ids',
|
||||
'content',
|
||||
'shelf_life',
|
||||
@@ -42,6 +44,7 @@ class StoreOrderItemModel extends Model
|
||||
protected $casts = [
|
||||
'order_id' => 'integer',
|
||||
'purchase_id' => 'integer',
|
||||
'bill_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'category_id' => 'integer',
|
||||
|
||||
@@ -48,27 +48,20 @@ class StoreOrderModel extends Model
|
||||
'total_quantity',
|
||||
'total_weight',
|
||||
'total_amount',
|
||||
'product_amount',
|
||||
'added_amount',
|
||||
'box_num',
|
||||
'tray_num',
|
||||
'status',
|
||||
'remark',
|
||||
'purchase_id',
|
||||
'bill_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'store_id' => 'integer',
|
||||
'purchase_id' => 'integer',
|
||||
'statement_id' => 'integer',
|
||||
'bill_id' => 'integer',
|
||||
'order_date' => 'date:Y-m-d',
|
||||
'total_quantity' => 'integer',
|
||||
'total_weight' => 'decimal:3',
|
||||
'total_amount' => 'decimal:2',
|
||||
'product_amount' => 'decimal:2',
|
||||
'added_amount' => 'decimal:2',
|
||||
'box_num' => 'integer',
|
||||
'tray_num' => 'integer',
|
||||
'status' => 'integer',
|
||||
'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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购单
|
||||
*/
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联账单(采购单完成后按门店生成)
|
||||
*/
|
||||
public function bill(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BillModel::class, 'bill_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,4 @@ class SupplierModel extends Model
|
||||
{
|
||||
return $this->hasMany(ProductModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定本供应商的小程序用户
|
||||
*/
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* APP 用户模型(小程序端:门店 / 供应商用户)
|
||||
*/
|
||||
class UserModel extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/** 状态:停用 */
|
||||
public const int STATUS_DISABLED = 0;
|
||||
/** 状态:正常 */
|
||||
public const int STATUS_NORMAL = 1;
|
||||
|
||||
protected $table = 'user';
|
||||
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'username',
|
||||
'email',
|
||||
'password',
|
||||
'nickname',
|
||||
'openid',
|
||||
'unionid',
|
||||
'phone',
|
||||
'avatar',
|
||||
'store_id',
|
||||
'status',
|
||||
'last_login_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime:Y-m-d H:i:s',
|
||||
'last_login_at' => 'datetime:Y-m-d H:i:s',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'store_id' => 'integer',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联门店
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
|
||||
}
|
||||
/**
|
||||
* 用户通知
|
||||
*/
|
||||
public function notices(): HasMany
|
||||
{
|
||||
return $this->hasMany(NoticeModel::class, 'user_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\WechatService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionsHandler;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -18,9 +17,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
||||
$this->app->bind(ExceptionsHandler::class, \App\Exceptions\ExceptionsHandler::class);
|
||||
|
||||
// 单例:测试通过 setHttpClient() 注入 Mock 后,控制器解析到同一实例
|
||||
$this->app->singleton(WechatService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,14 @@ class BillNumberService
|
||||
private const NUMBER_SOURCES = [
|
||||
'PO' => ['purchase_order', 'purchase_no'],
|
||||
'SO' => ['store_order', 'order_no'],
|
||||
'RC' => ['reconciliation', 'recon_no'],
|
||||
'ST' => ['statement', 'statement_no'],
|
||||
'JS' => ['settlement', 'settlement_no'],
|
||||
'ZD' => ['bill', 'bill_no'],
|
||||
'ZF' => ['payment', 'payment_no'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成业务单号
|
||||
*
|
||||
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算
|
||||
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / ZD 账单 / ZF 支付
|
||||
* @return string 如 PO202607230001
|
||||
*/
|
||||
public function make(string $prefix): string
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user