Compare commits

..

16 Commits

Author SHA1 Message Date
xinadmin 3005646179 打包前端 2026-09-07 23:28:54 +08:00
xinadmin edf5e42c38 售后金额上浮 2026-09-07 23:27:56 +08:00
xinadmin 58c733baa2 账单总金额 2026-09-07 23:07:12 +08:00
xinadmin 7de51391e4 批量对账 2026-09-07 22:53:50 +08:00
xinadmin 379aeac2b3 前端打包 2026-09-07 12:25:59 +08:00
xinadmin a636bd31b5 前端打包 2026-09-07 12:21:40 +08:00
xinadmin b784210012 账单样式 2026-09-07 12:21:01 +08:00
xinadmin 51ccaaf11a 账单价格 2026-09-06 23:20:00 +08:00
xinadmin 7a895c143e 对账标记 2026-09-06 22:48:45 +08:00
xinadmin f9eb6f7d32 导出时间 2026-09-06 22:37:46 +08:00
xinadmin f8e81cb830 导出 2026-09-06 22:34:25 +08:00
xinadmin 52f396c12e 截单时间 2026-09-05 19:40:52 +08:00
xinadmin b1bb40a088 排序 2026-09-05 15:17:58 +08:00
xinadmin 12cbed411b 默认重量 2026-09-05 14:37:22 +08:00
xinadmin 364471a086 前端打包 2026-09-05 14:05:16 +08:00
xinadmin 5c7a5829af 导出优化 2026-09-05 14:03:23 +08:00
39 changed files with 1148 additions and 126 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -94,7 +94,7 @@ class BillExport implements FromCollection, WithStyles
->all(); ->all();
$rows[] = ['账单号:' . $billNoText]; $rows[] = ['账单号:' . $billNoText];
$this->specialRows[++$rowIndex] = 'scope'; $this->specialRows[++$rowIndex] = 'scope';
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now()->format('Y-m-d H:i:s')]; $rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope'; $this->specialRows[++$rowIndex] = 'scope';
// 空行 // 空行
+1 -1
View File
@@ -92,7 +92,7 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
$this->specialRows[++$rowIndex] = 'title'; $this->specialRows[++$rowIndex] = 'title';
// 范围行:门店 / 导出时间 // 范围行:门店 / 导出时间
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now()->format('Y-m-d H:i:s')]; $rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope'; $this->specialRows[++$rowIndex] = 'scope';
// 空行 // 空行
+26 -10
View File
@@ -20,8 +20,10 @@ use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/** /**
* 采购单商品明细导出:系统全部商品行(含本采购单无订货的商品,数量 0), * 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色 * 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏);
* 市场/数量/门店列列宽减半、列头自动换行;门店数量无数据显示空白(不显示 0)
*/ */
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
{ {
@@ -114,10 +116,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
]; ];
} }
// 导出范围:系统全部上架商品 本采购单有订货的商品(含已删/下架 // 导出范围:系统全部未删除商品(含下架) 本采购单有订货的商品(含已删)
$products = ProductModel::withTrashed() $products = ProductModel::withTrashed()
->with('category:id,sort') ->with('category:id,sort')
->where('status', ProductModel::STATUS_ON) ->whereNull('deleted_at')
->orWhereIn('id', array_keys($itemGroups)) ->orWhereIn('id', array_keys($itemGroups))
->get() ->get()
->keyBy('id'); ->keyBy('id');
@@ -182,7 +184,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
if ($this->supplierId > 0) { if ($this->supplierId > 0) {
$scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId); $scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId);
} }
$rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now()->format('Y-m-d H:i:s')]; $rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope'; $this->specialRows[++$rowIndex] = 'scope';
// 空行 // 空行
@@ -221,7 +223,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$item['quantity'], $item['quantity'],
$item['weight'], $item['weight'],
$item['amount'], $item['amount'],
], array_values($item['store_quantities'])); ], array_map(
static fn (int $qty): int|string => $qty > 0 ? $qty : '',
array_values($item['store_quantities']),
));
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2); $totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3); $totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2); $totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
@@ -230,10 +235,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
} }
} }
// 合计行(行合计 + 门店列合计) // 合计行(行合计 + 门店列合计;门店无数据显示空白
$rows[] = array_merge( $rows[] = array_merge(
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount], ['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
array_values($storeTotals), array_map(static fn (int $qty): int|string => $qty > 0 ? $qty : '', array_values($storeTotals)),
); );
$this->specialRows[++$rowIndex] = 'summary'; $this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex; $this->lastRow = $rowIndex;
@@ -255,7 +260,8 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$this->collection(); $this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1)); $sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 10, 20, 12, 12, 12, 8, 10, 12, 10, 12, 12]; // 市场/数量列宽减半
$widths = [6, 10, 20, 12, 6, 12, 8, 10, 12, 5, 12, 12];
foreach ($widths as $index => $width) { foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
} }
@@ -264,7 +270,12 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$storeCount = count($storeIds); $storeCount = count($storeIds);
$lastColumn = Coordinate::stringFromColumnIndex(12 + max($storeCount, 1)); $lastColumn = Coordinate::stringFromColumnIndex(12 + max($storeCount, 1));
foreach ($storeIds as $i => $storeId) { foreach ($storeIds as $i => $storeId) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(12); $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);
} }
// 全部单元格水平/垂直居中 // 全部单元格水平/垂直居中
@@ -273,6 +284,11 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
->setHorizontal(Alignment::HORIZONTAL_CENTER) ->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER); ->setVertical(Alignment::VERTICAL_CENTER);
// 列头自动换行(市场/数量/门店列较窄,表头文字折行显示)
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->headerRow)
->getAlignment()
->setWrapText(true);
// 表格区域(列头 → 合计行)添加所有边框 // 表格区域(列头 → 合计行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow) $sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
->getBorders() ->getBorders()
+30 -31
View File
@@ -30,7 +30,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
private ?Collection $rows = null; private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */ /** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header */
private array $specialRows = []; private array $specialRows = [];
/** 列头所在行索引 */ /** 列头所在行索引 */
@@ -42,9 +42,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
/** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */ /** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
private array $rowData = []; private array $rowData = [];
/** @var array{quantity: int, stores: array<int, int>} 合计行填色判断数据 */
private array $summaryTotals = ['quantity' => 0, 'stores' => []];
/** /**
* @param PurchaseOrderModel $purchase 采购单 * @param PurchaseOrderModel $purchase 采购单
* @param SupplierModel $supplier 供应商 * @param SupplierModel $supplier 供应商
@@ -64,7 +61,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
} }
/** /**
* 导出行:标题/空行/列头(品名、汇总、市场、各门店)/明细/合计 * 导出行:标题+备注(合并区)/空行(合并区)/列头(品名、汇总、市场、各门店)/明细
*/ */
public function collection(): Collection public function collection(): Collection
{ {
@@ -77,11 +74,16 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$rows = []; $rows = [];
$rowIndex = 0; $rowIndex = 0;
// 标题行 // 标题行(A1:C2 合并占两行),D1 起放采购单备注(D1:I2 合并,红色 22 号字)
$rows[] = [$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no]; $rows[] = [
$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no,
'',
'',
trim((string) $this->purchase->remark),
];
$this->specialRows[++$rowIndex] = 'title'; $this->specialRows[++$rowIndex] = 'title';
// 空行 // 空行(被标题/备注合并区域覆盖)
$rows[] = ['']; $rows[] = [''];
$rowIndex++; $rowIndex++;
@@ -91,8 +93,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->headerRow = $rowIndex; $this->headerRow = $rowIndex;
// 明细行(0 数量的门店格留空不填色) // 明细行(0 数量的门店格留空不填色)
$totalQuantity = 0;
$storeTotals = array_fill_keys($storeIds, 0);
foreach ($this->productRows as $productRow) { foreach ($this->productRows as $productRow) {
$line = [ $line = [
$productRow['product_name'], $productRow['product_name'],
@@ -104,24 +104,14 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0); $quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
$line[] = $quantity > 0 ? $quantity : ''; $line[] = $quantity > 0 ? $quantity : '';
$storeQuantities[$storeId] = $quantity; $storeQuantities[$storeId] = $quantity;
$storeTotals[$storeId] += $quantity;
} }
$rows[] = $line; $rows[] = $line;
$this->rowData[++$rowIndex] = [ $this->rowData[++$rowIndex] = [
'quantity' => (int) $productRow['quantity'], 'quantity' => (int) $productRow['quantity'],
'store_quantities' => $storeQuantities, 'store_quantities' => $storeQuantities,
]; ];
$totalQuantity += (int) $productRow['quantity'];
} }
// 合计行
$rows[] = array_merge(
['合计', $totalQuantity, ''],
array_map(static fn (int $storeId): int => $storeTotals[$storeId], $storeIds),
);
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex; $this->lastRow = $rowIndex;
$this->summaryTotals = ['quantity' => $totalQuantity, 'stores' => $storeTotals];
return $this->rows = collect($rows); return $this->rows = collect($rows);
} }
@@ -132,7 +122,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
} }
/** /**
* 标题/列头/合计加粗;全表居中 + 全边框; * 标题合并 A1:C2、备注合并 D1:I2(红色 22 号字);标题/列头加粗;全表居中 + 全边框 + 列头行自动换行
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头 * 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
*/ */
public function styles(Worksheet $sheet): array public function styles(Worksheet $sheet): array
@@ -141,14 +131,18 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$sheet->freezePane('A' . ($this->headerRow + 1)); $sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [24, 10, 12]; // 标题合并前三列占两行(A1:C2),备注合并六列两行(D1:I2)
$sheet->mergeCells('A1:C2');
$sheet->mergeCells('D1:I2');
$widths = [24, 10, 6];
foreach ($widths as $index => $width) { foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
} }
$storeIds = array_map('intval', array_keys($this->stores)); $storeIds = array_map('intval', array_keys($this->stores));
$lastColumn = Coordinate::stringFromColumnIndex(3 + max(count($storeIds), 1)); $lastColumn = Coordinate::stringFromColumnIndex(3 + max(count($storeIds), 1));
foreach ($storeIds as $i => $storeId) { foreach ($storeIds as $i => $storeId) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(12); $sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(6);
} }
// 全部单元格水平/垂直居中 // 全部单元格水平/垂直居中
@@ -157,7 +151,15 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
->setHorizontal(Alignment::HORIZONTAL_CENTER) ->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_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) $sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
->getBorders() ->getBorders()
->getAllBorders() ->getAllBorders()
@@ -170,9 +172,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->fillCell($sheet, 'B' . $row, self::QUANTITY_FILL); $this->fillCell($sheet, 'B' . $row, self::QUANTITY_FILL);
} }
} }
if ($this->summaryTotals['quantity'] > 0) {
$this->fillCell($sheet, 'B' . $this->lastRow, self::QUANTITY_FILL);
}
// 门店数量列(D 起):有数据浅蓝,0 无背景,列头固定浅蓝 // 门店数量列(D 起):有数据浅蓝,0 无背景,列头固定浅蓝
foreach ($storeIds as $i => $storeId) { foreach ($storeIds as $i => $storeId) {
@@ -183,16 +182,13 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->fillCell($sheet, $column . $row, self::STORE_FILL); $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 = []; $styles = [];
foreach ($this->specialRows as $row => $type) { foreach ($this->specialRows as $row => $type) {
$style = match ($type) { $style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]], 'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]], 'header' => ['font' => ['bold' => true]],
default => [], default => [],
}; };
if ($style !== []) { if ($style !== []) {
@@ -200,6 +196,9 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
} }
} }
// 备注单元格(D1):红色 22 号字,须放在行样式之后应用以覆盖标题行字号
$styles['D1'] = ['font' => ['bold' => true, 'size' => 22, 'color' => ['argb' => 'FFFF0000']]];
return $styles; return $styles;
} }
+51 -2
View File
@@ -12,6 +12,7 @@ use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use App\Services\BillNumberService; use App\Services\BillNumberService;
use App\Services\ItemImageResolver; use App\Services\ItemImageResolver;
use App\Services\WeightEstimator;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -33,6 +34,10 @@ class OrderController extends BaseMiniController
public function store(MiniOrderRequest $request): JsonResponse public function store(MiniOrderRequest $request): JsonResponse
{ {
$store = $this->currentStore($request); $store = $this->currentStore($request);
// 截单时间校验:业务配置 services.order_time_start / order_time_end,均未配置时不限制
$this->assertWithinOrderTimeWindow();
$level = $store->level_id > 0 ? $store->level : null; $level = $store->level_id > 0 ? $store->level : null;
if ($level === null) { if ($level === null) {
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服'); throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
@@ -72,6 +77,7 @@ class OrderController extends BaseMiniController
$totalQuantity = '0'; $totalQuantity = '0';
$totalAmount = '0'; $totalAmount = '0';
$totalWeight = '0';
$now = now(); $now = now();
$rows = []; $rows = [];
foreach ($items as $row) { foreach ($items as $row) {
@@ -85,8 +91,11 @@ class OrderController extends BaseMiniController
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent); $price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
$quantity = (string) $row['quantity']; $quantity = (string) $row['quantity'];
$amount = bcmul($price, $quantity, 2); $amount = bcmul($price, $quantity, 2);
// 参考重量 = 订货量 × 规格折算(仅作参考,实际称重以采购录入为准)
$weight = WeightEstimator::estimate((string) $product->spec, (string) $product->unit, $quantity);
$totalQuantity = bcadd($totalQuantity, $quantity, 2); $totalQuantity = bcadd($totalQuantity, $quantity, 2);
$totalAmount = bcadd($totalAmount, $amount, 2); $totalAmount = bcadd($totalAmount, $amount, 2);
$totalWeight = bcadd($totalWeight, $weight, 3);
$rows[] = [ $rows[] = [
'store_id' => $store->id, 'store_id' => $store->id,
@@ -102,7 +111,7 @@ class OrderController extends BaseMiniController
'content' => (string) $product->content, 'content' => (string) $product->content,
'shelf_life' => (int) $product->shelf_life, 'shelf_life' => (int) $product->shelf_life,
'quantity' => $quantity, 'quantity' => $quantity,
'weight' => 0, 'weight' => $weight,
'amount' => $amount, 'amount' => $amount,
'cost_price' => (string) $product->cost_price, 'cost_price' => (string) $product->cost_price,
'remark' => '', 'remark' => '',
@@ -116,7 +125,7 @@ class OrderController extends BaseMiniController
'store_id' => $store->id, 'store_id' => $store->id,
'order_date' => $now->toDateString(), 'order_date' => $now->toDateString(),
'total_quantity' => $totalQuantity, 'total_quantity' => $totalQuantity,
'total_weight' => 0, 'total_weight' => $totalWeight,
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'status' => StoreOrderModel::STATUS_PENDING, 'status' => StoreOrderModel::STATUS_PENDING,
'remark' => $remark, 'remark' => $remark,
@@ -316,4 +325,44 @@ class OrderController extends BaseMiniController
return $this->success([], '订单已取消'); return $this->success([], '订单已取消');
} }
/**
* 截单时间校验:业务配置 services.order_time_start / order_time_endHH:mm
* 均留空不限制;只配一端按单边限制;开始时间晚于截单时间表示跨天时段(如 20:00-次日06:00);
* 格式非法的配置按未配置处理,避免误配置导致全天无法下单
*/
private function assertWithinOrderTimeWindow(): void
{
$parse = static function (mixed $value): ?int {
$value = trim((string) $value);
if (! preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $value, $matches)) {
return null;
}
return (int) $matches[1] * 60 + (int) $matches[2];
};
$startText = trim((string) site_config('services.order_time_start', ''));
$endText = trim((string) site_config('services.order_time_end', ''));
$start = $parse($startText);
$end = $parse($endText);
if ($start === null && $end === null) {
return;
}
if ($start !== null && $start === $end) {
return;
}
$now = (int) now()->format('H') * 60 + (int) now()->format('i');
$allowed = match (true) {
// 跨天时段:当晚开始时间之后 或 次日截单时间之前
$start !== null && $end !== null && $start > $end => $now >= $start || $now <= $end,
$start !== null && $end !== null => $now >= $start && $now <= $end,
$start !== null => $now >= $start,
default => $now <= $end,
};
if (! $allowed) {
$window = ($startText !== '' ? $startText : '00:00') . ' - ' . ($endText !== '' ? $endText : '24:00');
throw new RepositoryException('当前不在下单时段内(下单时间 ' . $window . '),请在规定时间内下单');
}
}
} }
@@ -60,7 +60,7 @@ class ProductController extends BaseMiniController
} }
$pageSize = (int) $request->input('pageSize', 10); $pageSize = (int) $request->input('pageSize', 10);
$paginator = $query->orderBy('sort', 'desc') $paginator = $query->orderBy('sort')
->orderBy('id') ->orderBy('id')
->paginate($pageSize); ->paginate($pageSize);
@@ -51,7 +51,7 @@ class ProductController extends BaseController
$params, $params,
ProductModel::query()->with(['category:id,name', 'supplier:id,name']) ProductModel::query()->with(['category:id,name', 'supplier:id,name'])
) )
->orderBy('sort', 'desc') ->orderBy('sort')
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->paginate($pageSize); ->paginate($pageSize);
$data->getCollection()->makeVisible('cost_price'); $data->getCollection()->makeVisible('cost_price');
@@ -13,6 +13,7 @@ use App\Http\Requests\Purchase\PurchaseStoreItemRequest;
use App\Models\BillModel; use App\Models\BillModel;
use App\Models\CustomerLevelModel; use App\Models\CustomerLevelModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\PurchaseItemCheckModel;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
use App\Models\StoreModel; use App\Models\StoreModel;
use App\Models\StoreOrderItemModel; use App\Models\StoreOrderItemModel;
@@ -22,6 +23,7 @@ use App\Services\BillGenerateService;
use App\Services\ItemImageResolver; use App\Services\ItemImageResolver;
use App\Services\PurchaseGenerateService; use App\Services\PurchaseGenerateService;
use App\Services\PurchaseItemService; use App\Services\PurchaseItemService;
use App\Services\WeightEstimator;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -53,7 +55,10 @@ class PurchaseOrderController extends BaseController
{ {
$params = $request->all(); $params = $request->all();
$pageSize = $params['pageSize'] ?? 10; $pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, PurchaseOrderModel::query()->with('operator:id,nickname')) $data = $this->buildSearch($params, PurchaseOrderModel::query()
->with('operator:id,nickname')
// 应付商品金额 = Σ 已生成门店账单的商品金额(实际口径;未生成账单为 null)
->withSum('bills as bill_product_amount', 'product_amount'))
->orderBy('purchase_date', 'desc') ->orderBy('purchase_date', 'desc')
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->paginate($pageSize) ->paginate($pageSize)
@@ -172,6 +177,12 @@ class PurchaseOrderController extends BaseController
return $row; return $row;
}, $rows), }, $rows),
'bills' => $bills, 'bills' => $bills,
// 单品「已对账」标记(入库持久化,商品ID列表)
'checked_product_ids' => PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->pluck('product_id')
->map(static fn ($v) => (int) $v)
->all(),
]); ]);
} }
@@ -481,7 +492,10 @@ class PurchaseOrderController extends BaseController
'content' => (string) $product->content, 'content' => (string) $product->content,
'shelf_life' => (int) $product->shelf_life, 'shelf_life' => (int) $product->shelf_life,
'quantity' => $quantity, 'quantity' => $quantity,
'weight' => bcadd((string) ($validated['weight'] ?? 0), '0', 3), // 未传称重时按订货量 × 规格预填参考重量
'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), 'amount' => bcmul($price, (string) $quantity, 2),
'cost_price' => (string) $product->cost_price, 'cost_price' => (string) $product->cost_price,
'remark' => '', 'remark' => '',
@@ -609,8 +623,9 @@ class PurchaseOrderController extends BaseController
->get(['id', 'store_id', 'total_amount']); ->get(['id', 'store_id', 'total_amount']);
$stores = StoreModel::withTrashed() $stores = StoreModel::withTrashed()
->with('level:id,name,percent')
->whereIn('id', $orders->pluck('store_id')->unique()) ->whereIn('id', $orders->pluck('store_id')->unique())
->get(['id', 'name']) ->get(['id', 'name', 'level_id'])
->keyBy('id'); ->keyBy('id');
$bills = BillModel::query() $bills = BillModel::query()
@@ -628,9 +643,13 @@ class PurchaseOrderController extends BaseController
'0' '0'
); );
$bill = $bills->get((int) $storeId); $bill = $bills->get((int) $storeId);
$store = $stores->get((int) $storeId);
$rows[] = [ $rows[] = [
'store_id' => (int) $storeId, 'store_id' => (int) $storeId,
'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId), 'store_name' => $store->name ?? ('门店#' . $storeId),
// 客户等级(售后金额上浮折算预览用;无等级=不上浮)
'level_name' => $store?->level?->name,
'level_percent' => (string) ($store?->level?->percent ?? '0'),
'order_count' => $storeOrders->count(), 'order_count' => $storeOrders->count(),
'product_amount' => $productAmount, 'product_amount' => $productAmount,
'box_price' => $boxPrice, 'box_price' => $boxPrice,
@@ -791,6 +810,103 @@ class PurchaseOrderController extends BaseController
}); });
} }
/**
* 切换单品「已对账」标记(入库持久化:未标记→标记,已标记→取消;与采购单状态无关,对账期间可反复勾选)
*/
#[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()], '已标记为已对账');
}
/** /**
* 采购单编辑闸:仅进行中(待采购)允许修改明细 * 采购单编辑闸:仅进行中(待采购)允许修改明细
*/ */
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel;
/**
* 采购单单品对账标记模型(商品明细「已对账」勾选,入库持久化;存在记录=已标记)
*/
class PurchaseItemCheckModel extends Model
{
protected $table = 'purchase_item_check';
protected $primaryKey = 'id';
protected $fillable = [
'purchase_id',
'product_id',
'operator_id',
];
protected $casts = [
'purchase_id' => 'integer',
'product_id' => 'integer',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 关联采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 标记人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
}
+10
View File
@@ -44,6 +44,8 @@ class PurchaseOrderModel extends Model
'actual_amount' => 'decimal:2', 'actual_amount' => 'decimal:2',
'operator_id' => 'integer', 'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s', 'created_at' => 'datetime:Y-m-d H:i:s',
// 列表 withSum 聚合属性(应付商品金额;无账单时保持 null)
'bill_product_amount' => 'decimal:2',
]; ];
/** /**
@@ -69,4 +71,12 @@ class PurchaseOrderModel extends Model
{ {
return $this->hasMany(StoreOrderModel::class, 'purchase_id', 'id'); return $this->hasMany(StoreOrderModel::class, 'purchase_id', 'id');
} }
/**
* 本采购单生成的门店账单
*/
public function bills(): HasMany
{
return $this->hasMany(BillModel::class, 'purchase_id', 'id');
}
} }
+2
View File
@@ -85,6 +85,8 @@ class BillDetailService
'quantity' => $quantity, 'quantity' => $quantity,
'weight' => $weight, 'weight' => $weight,
'amount' => $amount, 'amount' => $amount,
'spec' => $product->spec,
'price_unit' => $product->price_unit,
'image_ids' => (array) $first->image_ids, 'image_ids' => (array) $first->image_ids,
'category_sort' => (int) ($product->category->sort ?? 9999), 'category_sort' => (int) ($product->category->sort ?? 9999),
'product_sort' => (int) ($product->sort ?? 9999), 'product_sort' => (int) ($product->sort ?? 9999),
+16 -2
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Exceptions\RepositoryException; use App\Exceptions\RepositoryException;
use App\Models\BillModel; use App\Models\BillModel;
use App\Models\ContainerReturnModel; use App\Models\ContainerReturnModel;
use App\Models\CustomerLevelModel;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
use App\Models\StoreModel; use App\Models\StoreModel;
use App\Models\StoreOrderItemModel; use App\Models\StoreOrderItemModel;
@@ -20,7 +21,8 @@ use Throwable;
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改) * 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照; * 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
* 数量正数=压筐附加金额,负数=回筐抵扣金额) * 数量正数=压筐附加金额,负数=回筐抵扣金额)
* 4. 售后金额 = 按门店填写的调整金额(可正负:正数=加收,负数=售后减免) * 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
* 可正负:正数=加收,负数=售后减免)
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成 * 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负) * 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
*/ */
@@ -82,6 +84,14 @@ readonly class BillGenerateService
$boxPrice = (string) site_config('services.box_amount', 0); $boxPrice = (string) site_config('services.box_amount', 0);
$trayPrice = (string) site_config('services.tray_amount', 0); $trayPrice = (string) site_config('services.tray_amount', 0);
$billDate = now()->toDateString(); $billDate = now()->toDateString();
// 各门店客户等级上浮比例(售后金额折算用;无等级按 0 不上浮)
$levelPercents = StoreModel::withTrashed()
->with('level:id,percent')
->whereIn('id', $ordersByStore->keys())
->get(['id', 'level_id'])
->mapWithKeys(static fn (StoreModel $store) => [
$store->id => (string) ($store->level?->percent ?? '0'),
]);
$bills = []; $bills = [];
foreach ($ordersByStore as $storeId => $storeOrders) { foreach ($ordersByStore as $storeId => $storeOrders) {
$row = $submitted[(int) $storeId]; $row = $submitted[(int) $storeId];
@@ -96,7 +106,11 @@ readonly class BillGenerateService
bcmul((string) (int) $row['tray_num'], $trayPrice, 2), bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
2 2
); );
$afterSale = bcadd((string) ($row['after_sale'] ?? '0'), '0', 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); $totalAmount = bcadd(bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2), $afterSale, 2);
$bill = BillModel::create([ $bill = BillModel::create([
+6 -1
View File
@@ -63,13 +63,18 @@ readonly class PurchaseGenerateService
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2), static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
'0' '0'
); );
// 参考总重量 = Σ 明细参考重量(下单时按订货量 × 规格预填)
$totalWeight = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->weight, 3),
'0'
);
$purchase = PurchaseOrderModel::create([ $purchase = PurchaseOrderModel::create([
'purchase_no' => $this->billNumberService->make('PO'), 'purchase_no' => $this->billNumberService->make('PO'),
'purchase_date' => $date, 'purchase_date' => $date,
'status' => PurchaseOrderModel::STATUS_PENDING, 'status' => PurchaseOrderModel::STATUS_PENDING,
'total_quantity' => $totalQuantity, 'total_quantity' => $totalQuantity,
'total_weight' => 0, 'total_weight' => $totalWeight,
'estimate_amount' => $estimateAmount, 'estimate_amount' => $estimateAmount,
'actual_amount' => 0, 'actual_amount' => 0,
'operator_id' => $operatorId, 'operator_id' => $operatorId,
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Services;
/**
* 参考重量估算:订货量 × 单品规格折算(斤),仅作参考,实际称重以人工录入为准
*/
class WeightEstimator
{
/**
* 估算参考重量(斤,3 位小数)
*
* 计价单位本身是重量单位时订货量即重量;否则从规格(如「10斤/箱」「500g/袋」)
* 解析每件重量再乘订货量;规格无法解析时计 0
*/
public static function estimate(string $spec, string $unit, string $quantity): string
{
return bcmul($quantity === '' ? '0' : $quantity, self::perUnitJin($spec, $unit), 3);
}
/**
* 单个计价单位折算重量(斤)
*/
private static function perUnitJin(string $spec, string $unit): string
{
// 按重量计价的单位:订货量本身就是重量
$unitWeight = self::toJin('1', $unit);
if ($unitWeight !== null) {
return $unitWeight;
}
// 从规格解析每件重量(如「10斤/箱」「500g/袋」「1.5kg/箱」)
if (preg_match('/(\d+(?:\.\d+)?)\s*(公斤|千克|kg|克|斤|g)/iu', $spec, $matches) === 1) {
$parsed = self::toJin($matches[1], $matches[2]);
if ($parsed !== null) {
return $parsed;
}
}
// 规格中的裸数字按斤计(如「10/箱」);无法解析计 0
if (preg_match('/\d+(?:\.\d+)?/', $spec, $matches) === 1) {
return $matches[0];
}
return '0';
}
/**
* 数值按单位折算为斤;非重量单位返回 null(1斤=500g1公斤/千克/kg=2斤)
*/
private static function toJin(string $value, string $unit): ?string
{
return match (mb_strtolower(trim($unit))) {
'斤' => $value,
'公斤', '千克', 'kg' => bcmul($value, '2', 3),
'克', 'g' => bcdiv($value, '500', 3),
default => null,
};
}
}
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 采购单单品对账标记:商品明细「已对账」勾选入库持久化(替代原前端 localStorage 本地标记)
*/
public function up(): void
{
if (! Schema::hasTable('purchase_item_check')) {
Schema::create('purchase_item_check', function (Blueprint $table) {
$table->increments('id')->comment('标记ID');
$table->integer('purchase_id')->comment('采购单ID');
$table->integer('product_id')->comment('商品ID');
$table->integer('operator_id')->default(0)->comment('标记人(后台系统用户ID');
$table->timestamps();
$table->unique(['purchase_id', 'product_id'], 'purchase_item_check_unique');
$table->comment('采购单单品对账标记表(商品明细「已对账」勾选)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('purchase_item_check');
}
};
+2
View File
@@ -29,6 +29,8 @@ class SysDataSeeder extends Seeder
['id' => 4, 'group_id' => 1, 'key' => 'describe', 'title' => '网站描述', 'describe' => '网站的基本描述', 'values' => '没有描述', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date], ['id' => 4, 'group_id' => 1, 'key' => 'describe', 'title' => '网站描述', 'describe' => '网站的基本描述', 'values' => '没有描述', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 7, 'group_id' => 3, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '周转筐的金额,用于附加业务金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date], ['id' => 7, 'group_id' => 3, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '周转筐的金额,用于附加业务金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 8, 'group_id' => 3, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '周转托盘的金额,用于附加金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date], ['id' => 8, 'group_id' => 3, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '周转托盘的金额,用于附加金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 36, 'group_id' => 3, 'key' => 'order_time_start', 'title' => '下单开始时间', 'describe' => '允许下单的开始时间(24小时制 HH:mm,如 08:00),留空不限制;开始时间晚于截单时间表示跨天时段(如 20:00 至次日 06:00', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 37, 'group_id' => 3, 'key' => 'order_time_end', 'title' => '截单时间', 'describe' => '下单截止时间(24小时制 HH:mm,如 18:00),不在下单时段内门店不能下单,留空不限制', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
// 支付配置(pay):收款方式与在线支付渠道 // 支付配置(pay):收款方式与在线支付渠道
['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date], ['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date], ['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t,n){return e({url:`/purchase/order/generate`,method:`post`,data:{purchase_date:t,order_ids:n}})}async function r(t){return e({url:`/purchase/order/${t}`,method:`get`})}async function i(t,n,r){return e({url:`/purchase/order/${t}/row/${n}`,method:`put`,data:r})}async function a(t,n){return e({url:`/purchase/order/${t}/check/${n}`,method:`put`})}async function o(t,n,r){return e({url:`/purchase/order/${t}/check`,method:`put`,data:{product_ids:n,checked:r}})}async function s(t,n){return e({url:`/purchase/order/${t}/store`,method:`get`,params:{store_id:n}})}async function c(t){return e({url:`/purchase/order/${t}/bill/prepare`,method:`get`})}async function l(t,n){return e({url:`/purchase/order/${t}/bill`,method:`post`,data:{stores:n}})}async function u(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item`,method:`post`,data:r})}async function d(t,n,r,i){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`put`,data:i})}async function f(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`delete`})}async function p(e,n){return t(`/purchase/order/${e}/export`,n?{supplier_id:n}:{},`采购单_${e}.xlsx`)}async function m(e,n){return t(`/purchase/order/${e}/exportStores`,n?{store_id:n}:{},`门店购买详情_${e}.xlsx`)}async function h(e,n){return t(`/purchase/order/${e}/exportSuppliers`,n?{supplier_id:n}:{},`供应商采购明细_${e}.xlsx`)}export{h as a,c,f as d,a as f,m as i,r as l,d as m,o as n,l as o,i as p,p as r,n as s,u as t,s as u};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t,n){return e({url:`/purchase/order/generate`,method:`post`,data:{purchase_date:t,order_ids:n}})}async function r(t){return e({url:`/purchase/order/${t}`,method:`get`})}async function i(t,n,r){return e({url:`/purchase/order/${t}/row/${n}`,method:`put`,data:r})}async function a(t,n){return e({url:`/purchase/order/${t}/store`,method:`get`,params:{store_id:n}})}async function o(t){return e({url:`/purchase/order/${t}/bill/prepare`,method:`get`})}async function s(t,n){return e({url:`/purchase/order/${t}/bill`,method:`post`,data:{stores:n}})}async function c(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item`,method:`post`,data:r})}async function l(t,n,r,i){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`put`,data:i})}async function u(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`delete`})}async function d(e,n){return t(`/purchase/order/${e}/export`,n?{supplier_id:n}:{},`采购单_${e}.xlsx`)}async function f(e,n){return t(`/purchase/order/${e}/exportStores`,n?{store_id:n}:{},`门店购买详情_${e}.xlsx`)}async function p(e,n){return t(`/purchase/order/${e}/exportSuppliers`,n?{supplier_id:n}:{},`供应商采购明细_${e}.xlsx`)}export{s as a,r as c,i as d,l as f,p as i,a as l,d as n,n as o,f as r,o as s,c as t,u};
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicons.svg" /> <link rel="icon" type="image/svg+xml" href="/favicons.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XinAdmin</title> <title>XinAdmin</title>
<script type="module" crossorigin src="/assets/index-B3rYtk3Q.js"></script> <script type="module" crossorigin src="/assets/index-CrAEpRIR.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js"> <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js">
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js"> <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js"> <link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js">
@@ -92,7 +92,7 @@
<link rel="modulepreload" crossorigin href="/assets/useMobile-Bcq0nkW4.js"> <link rel="modulepreload" crossorigin href="/assets/useMobile-Bcq0nkW4.js">
<link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js"> <link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js">
<link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js"> <link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js">
<link rel="stylesheet" crossorigin href="/assets/index-BeViyrx7.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bz9U6gHM.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+50
View File
@@ -133,6 +133,56 @@ class BillPaymentTest extends ProcurementTestCase
$this->assertSame('5.00', (string) $bill2->total_amount, '未传售后金额不影响总金额'); $this->assertSame('5.00', (string) $bill2->total_amount, '未传售后金额不影响总金额');
} }
/** 售后金额按客户等级上浮折算:输入 15、上浮 1% → 实收 15.15billPrepare 回显等级(负数同比例放大) */
public function test_generate_bill_after_sale_marked_up_by_level(): void
{
$level = CustomerLevelModel::factory()->create(['percent' => 1]);
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$level2 = CustomerLevelModel::factory()->create(['percent' => 2]);
$store2 = StoreModel::factory()->create(['level_id' => $level2->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
// 两店各下一单:店1 等级价 5.05 × 4 = 20.20;店2 等级价 5.10 × 2 = 10.20
foreach ([[$store, 4], [$store2, 2]] as [$s, $qty]) {
$this->actingAsMiniStore($s);
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
$this->putJson("/purchase/order/{$purchase->id}/finish")->assertJsonPath('success', true);
// billPrepare 回显客户等级与上浮比例(弹窗预览用)
$prepare = $this->getJson("/purchase/order/{$purchase->id}/bill/prepare")
->assertJsonPath('success', true)
->json('data.stores');
$prepareByStore = collect($prepare)->keyBy('store_id');
$this->assertSame($level->name, $prepareByStore[$store->id]['level_name']);
$this->assertSame('1.00', (string) $prepareByStore[$store->id]['level_percent']);
$this->assertSame($level2->name, $prepareByStore[$store2->id]['level_name']);
$this->assertSame('2.00', (string) $prepareByStore[$store2->id]['level_percent']);
// 店1 售后 15 × 101% = 15.15;店2 售后 -10 × 102% = -10.20
$this->postJson("/purchase/order/{$purchase->id}/bill", [
'stores' => [
['store_id' => $store->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0, 'after_sale' => 15],
['store_id' => $store2->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0, 'after_sale' => -10],
],
])->assertJsonPath('success', true);
$bill = BillModel::where('store_id', $store->id)->first();
$this->assertSame('15.15', (string) $bill->after_sale, '15 × 101%');
$this->assertSame('35.35', (string) $bill->total_amount, '商品 20.20 + 售后 15.15');
$bill2 = BillModel::where('store_id', $store2->id)->first();
$this->assertSame('-10.20', (string) $bill2->after_sale, '-10 × 102%(负数同比例放大)');
$this->assertSame('0.00', (string) $bill2->total_amount, '商品 10.20 + 售后 -10.20');
}
/** 生成账单:售后金额为正数时加收计入总金额 */ /** 生成账单:售后金额为正数时加收计入总金额 */
public function test_generate_bill_positive_after_sale_added_to_total(): void public function test_generate_bill_positive_after_sale_added_to_total(): void
{ {
+48 -4
View File
@@ -13,6 +13,7 @@ use App\Models\StoreModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use App\Models\SupplierModel; use App\Models\SupplierModel;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
/** /**
* 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type / * 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type /
@@ -188,6 +189,48 @@ class ExportTest extends ProcurementTestCase
); );
} }
/** 商品明细导出:序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出) */
public function test_export_hides_detail_columns_by_default(): void
{
[$purchase] = $this->buildPurchaseWithSuppliers();
$export = new PurchaseOrderExport($purchase);
$sheet = (new Spreadsheet())->getActiveSheet();
$export->styles($sheet);
// 序号A/分类B/包规F/单位G/成本H/单价I/实际称重K/金额L 默认隐藏
foreach (['A', 'B', 'F', 'G', 'H', 'I', 'K', 'L'] as $column) {
$this->assertFalse($sheet->getColumnDimension($column)->getVisible(), "{$column} 应默认隐藏");
}
// 品名/供应商/市场/数量与门店列保持可见
foreach (['C', 'D', 'E', 'J', 'M', 'N'] as $column) {
$this->assertTrue($sheet->getColumnDimension($column)->getVisible(), "{$column} 应保持可见");
}
// 数据照常导出:列头与合计行仍含隐藏列数据
$rows = $export->collection()->values();
$header = $rows[3];
$this->assertSame('序号', $header[0]);
$this->assertSame('金额', $header[11]);
$total = $rows->last();
$this->assertSame('合计', $total[2]);
$this->assertSame(45.0, (float) $total[11]);
}
/** 商品明细导出:范围=全部未删除商品(含下架)∪ 本采购单有订货商品;已删除且无订货的商品不导出 */
public function test_export_scope_includes_off_shelf_but_not_deleted(): void
{
[$purchase] = $this->buildPurchaseWithSuppliers();
$offShelf = ProductModel::factory()->create(['status' => ProductModel::STATUS_OFF, 'cost_price' => '8.00']);
$deleted = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '9.00']);
$deleted->delete();
$rows = (new PurchaseOrderExport($purchase))->collection()->values();
$names = $rows->slice(4, -1)->map(static fn ($row) => $row[2])->all();
$this->assertContains($offShelf->name, $names);
$this->assertNotContains($deleted->name, $names);
}
/** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */ /** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */
public function test_export_supplier_filter(): void public function test_export_supplier_filter(): void
{ {
@@ -307,8 +350,9 @@ class ExportTest extends ProcurementTestCase
|| (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) { || (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) {
return false; return false;
} }
$totalA = $rowsA->last(); // 无合计行:末行即最后一条明细
if ($totalA[0] !== '合计' || (int) $totalA[1] !== 5 || (int) $totalA[3] !== 2 || (int) $totalA[4] !== 3) { $lastA = $rowsA->last();
if ($lastA[0] === '合计') {
return false; return false;
} }
// 供应商乙·岳各庄:肉 1 件;门店列仅门店A // 供应商乙·岳各庄:肉 1 件;门店列仅门店A
@@ -366,10 +410,10 @@ class ExportTest extends ProcurementTestCase
if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) { if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) {
return false; return false;
} }
// 每个工作表仅含本市场商品 // 每个工作表仅含本市场商品(无合计行,明细自第 4 行起)
foreach ($sheets as $sheet) { foreach ($sheets as $sheet) {
$rows = $sheet->collection()->values(); $rows = $sheet->collection()->values();
$names = $rows->slice(3, -1)->map(static fn ($row) => $row[0])->values()->all(); $names = $rows->slice(3)->map(static fn ($row) => $row[0])->values()->all();
$expected = match (true) { $expected = match (true) {
str_ends_with($sheet->title(), '新发地') => [$vegA->name], str_ends_with($sheet->title(), '新发地') => [$vegA->name],
str_ends_with($sheet->title(), '岳各庄') => [$vegB->name], str_ends_with($sheet->title(), '岳各庄') => [$vegB->name],
+3 -3
View File
@@ -106,7 +106,7 @@ class MiniProductTest extends ProcurementTestCase
$this->assertNull($row['price']); $this->assertNull($row['price']);
} }
/** 商品列表:先按分类树展示顺序(深度优先),同分类内按商品 sort 序、id 升序,未入树分类排最后 */ /** 商品列表:先按分类树展示顺序(深度优先),同分类内按商品 sort 序、id 升序,未入树分类排最后 */
public function test_product_list_ordered_by_category_tree(): void public function test_product_list_ordered_by_category_tree(): void
{ {
$rootA = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); $rootA = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
@@ -123,8 +123,8 @@ class MiniProductTest extends ProcurementTestCase
$ids = array_column($this->getJson('/mini/product/list')->assertOk()->json('data.data'), 'id'); $ids = array_column($this->getJson('/mini/product/list')->assertOk()->json('data.data'), 'id');
// 树序:rootA → leafA2 → leafA1 → rootBleafA2 内 sort 序;无分类排最后 // 树序:rootA → leafA2 → leafA1 → rootBleafA2 内 sort 序;无分类排最后
$this->assertSame([$pA2High->id, $pA2Low->id, $pA1->id, $pB->id, $pNone->id], $ids); $this->assertSame([$pA2Low->id, $pA2High->id, $pA1->id, $pB->id, $pNone->id], $ids);
} }
/** 商品列表:登录门店附加购物车行ID与数量,响应附悬浮球汇总 */ /** 商品列表:登录门店附加购物车行ID与数量,响应附悬浮球汇总 */
+182 -2
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\CustomerLevelModel; use App\Models\CustomerLevelModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\PurchaseItemCheckModel;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
use App\Models\StoreModel; use App\Models\StoreModel;
use App\Models\StoreOrderItemModel; use App\Models\StoreOrderItemModel;
@@ -282,7 +283,7 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame('50.00', (string) $order->total_amount); $this->assertSame('50.00', (string) $order->total_amount);
$purchase = $purchase->fresh(); $purchase = $purchase->fresh();
$this->assertSame('4.500', (string) $purchase->total_weight); $this->assertSame('7.500', (string) $purchase->total_weight, '4.500 手动称重 + B 店参考重量 3.000');
$this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)'); $this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)');
} }
@@ -392,7 +393,7 @@ class PurchaseEditTest extends ProcurementTestCase
$purchase = $purchase->fresh(); $purchase = $purchase->fresh();
$this->assertSame('9.00', (string) $purchase->total_quantity, '2+3+4'); $this->assertSame('9.00', (string) $purchase->total_quantity, '2+3+4');
$this->assertSame('82.00', (string) $purchase->estimate_amount, '50+324 包 × 成本 8.00'); $this->assertSame('82.00', (string) $purchase->estimate_amount, '50+324 包 × 成本 8.00');
$this->assertSame('1.500', (string) $purchase->total_weight); $this->assertSame('6.500', (string) $purchase->total_weight, '原有参考重量 2+3 斤 + 新增 1.500');
// 重复添加同一商品 → 拒绝 // 重复添加同一商品 → 拒绝
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [ $this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
@@ -402,6 +403,28 @@ class PurchaseEditTest extends ProcurementTestCase
->assertJsonPath('msg', '该商品已在此门店采购明细中,请直接修改数量'); ->assertJsonPath('msg', '该商品已在此门店采购明细中,请直接修改数量');
} }
/** 门店购买详情-新增单品未传称重:按订货量 × 规格预填参考重量并级联汇总 */
public function test_store_item_add_defaults_reference_weight(): void
{
[$purchase, , $stores] = $this->buildPurchase();
$extra = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => 8,
'spec' => '25斤/袋',
'unit' => '袋',
]);
$this->actingAsSysUser();
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
'product_id' => $extra->id,
'quantity' => 2,
])->assertOk()->assertJsonPath('success', true);
$item = StoreOrderItemModel::where('product_id', $extra->id)->first();
$this->assertSame('50.000', (string) $item->weight, '2 袋 × 25斤/袋');
$this->assertSame('55.000', (string) $purchase->fresh()->total_weight, '原有参考重量 5.000 + 新增 50.000');
}
/** 门店购买详情-修改单品:同商品多笔订单明细合并到最早一条,涉及订单逐一重算 */ /** 门店购买详情-修改单品:同商品多笔订单明细合并到最早一条,涉及订单逐一重算 */
public function test_store_item_update_merges_multi_order_rows(): void public function test_store_item_update_merges_multi_order_rows(): void
{ {
@@ -585,4 +608,161 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变'); $this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变');
$this->assertSame('10.00', (string) $item->fresh()->cost_price); $this->assertSame('10.00', (string) $item->fresh()->cost_price);
} }
/** 单品「已对账」标记:切换入库持久化,详情接口回显,再次切换取消 */
public function test_item_check_toggle_persists_and_echoes_in_detail(): void
{
[$purchase, $product] = $this->buildPurchase();
$this->actingAsSysUser();
// 初始无标记
$this->getJson("/purchase/order/{$purchase->id}")
->assertJsonPath('success', true)
->assertJsonPath('data.checked_product_ids', []);
// 标记 → 入库
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
->assertJsonPath('success', true)
->assertJsonPath('data.checked', true);
$this->assertDatabaseHas('purchase_item_check', [
'purchase_id' => $purchase->id,
'product_id' => $product->id,
]);
// 详情回显
$this->getJson("/purchase/order/{$purchase->id}")
->assertJsonPath('data.checked_product_ids.0', $product->id);
// 再次切换 → 取消标记,记录删除
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
->assertJsonPath('success', true)
->assertJsonPath('data.checked', false);
$this->assertDatabaseMissing('purchase_item_check', [
'purchase_id' => $purchase->id,
'product_id' => $product->id,
]);
}
/** 对账标记与采购单状态无关:已完成采购单仍可切换标记 */
public function test_item_check_allowed_when_purchase_completed(): void
{
[$purchase, $product] = $this->buildPurchase();
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
->assertJsonPath('success', true)
->assertJsonPath('data.checked', true);
}
/** 对账标记校验:采购单不存在 / 采购单内无此商品均报错,不入库 */
public function test_item_check_toggle_validates_purchase_and_product(): void
{
[$purchase] = $this->buildPurchase();
$this->actingAsSysUser();
$this->putJson('/purchase/order/99999/check/1')
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单不存在');
$this->putJson("/purchase/order/{$purchase->id}/check/99999")
->assertJsonPath('success', false)
->assertJsonPath('msg', '该采购单下无此商品的订货明细');
$this->assertSame(0, PurchaseItemCheckModel::count());
}
/** 批量标记(全选):一次标记多个商品,重复标记幂等,批量取消仅清指定商品 */
public function test_item_check_batch_marks_and_clears(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
$this->actingAsSysUser();
// 采购单内再加一个商品(通过新增单品端点挂靠门店订单)
$product2 = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => 5,
'spec' => '1斤',
'unit' => '斤',
]);
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
'product_id' => $product2->id,
'quantity' => 1,
])->assertJsonPath('success', true);
// 批量标记两个商品
$this->putJson("/purchase/order/{$purchase->id}/check", [
'product_ids' => [$product->id, $product2->id],
'checked' => true,
])->assertJsonPath('success', true)
->assertJsonPath('data.count', 2);
$this->assertSame(2, PurchaseItemCheckModel::where('purchase_id', $purchase->id)->count());
// 重复批量标记幂等(不产生重复行)
$this->putJson("/purchase/order/{$purchase->id}/check", [
'product_ids' => [$product->id, $product2->id],
'checked' => true,
])->assertJsonPath('success', true);
$this->assertSame(2, PurchaseItemCheckModel::where('purchase_id', $purchase->id)->count());
// 详情回显两个标记
$ids = $this->getJson("/purchase/order/{$purchase->id}")->json('data.checked_product_ids');
$this->assertEqualsCanonicalizing([$product->id, $product2->id], $ids);
// 批量取消其中一个
$this->putJson("/purchase/order/{$purchase->id}/check", [
'product_ids' => [$product->id],
'checked' => false,
])->assertJsonPath('success', true);
$this->assertDatabaseMissing('purchase_item_check', [
'purchase_id' => $purchase->id,
'product_id' => $product->id,
]);
$this->assertDatabaseHas('purchase_item_check', [
'purchase_id' => $purchase->id,
'product_id' => $product2->id,
]);
}
/** 批量标记校验:空列表/采购单不存在报错;无效商品静默忽略 */
public function test_item_check_batch_validates_and_ignores_unknown_products(): void
{
[$purchase] = $this->buildPurchase();
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/check", ['product_ids' => [], 'checked' => true])
->assertJsonPath('success', false);
$this->putJson('/purchase/order/99999/check', ['product_ids' => [1], 'checked' => true])
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单不存在');
// 采购单内无此商品 → 静默忽略不入库
$this->putJson("/purchase/order/{$purchase->id}/check", ['product_ids' => [99999], 'checked' => true])
->assertJsonPath('success', true)
->assertJsonPath('data.count', 0);
$this->assertSame(0, PurchaseItemCheckModel::count());
}
/** 采购单列表「应付商品金额」列 = Σ 已生成门店账单的商品金额(未生成账单为 null) */
public function test_list_includes_bill_product_amount(): void
{
[$purchase, , $stores] = $this->buildPurchase();
$this->actingAsSysUser();
// 未生成账单 → null
$this->getJson('/purchase/order')
->assertJsonPath('data.data.0.bill_product_amount', null);
// 完成采购单并生成两店账单(各店商品金额 = 数量 × 等级价 10.00)
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->postJson("/purchase/order/{$purchase->id}/bill", [
'stores' => [
['store_id' => $stores[0]->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0],
['store_id' => $stores[1]->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0],
],
])->assertJsonPath('success', true);
// 列表回显:2×10.00 + 3×10.00 = 50.00
$this->getJson('/purchase/order')
->assertJsonPath('data.data.0.bill_product_amount', '50.00');
}
} }
+26
View File
@@ -139,4 +139,30 @@ class PurchaseGenerateTest extends ProcurementTestCase
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单'); $this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
} }
/** 采购单总重量 = 订货明细参考重量合计(下单时已按订货量 × 规格预填) */
public function test_generate_sums_reference_weight(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '6.00',
'spec' => '10斤/箱',
'unit' => '箱',
]);
$this->actingAsMiniStore($store);
foreach ([3, 4] as $qty) {
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$this->assertSame('70.000', (string) PurchaseOrderModel::first()->total_weight, '(3+4) 箱 × 10斤/箱');
}
} }
+127
View File
@@ -9,6 +9,9 @@ use App\Models\PurchaseOrderModel;
use App\Models\StoreModel; use App\Models\StoreModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use Modules\SystemTool\Models\SysFileModel; use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Modules\SystemTool\Services\SysSiteConfigService;
/** /**
* 小程序下单:等级上浮价快照、服务端重算总价、取消限制、门店数据隔离 * 小程序下单:等级上浮价快照、服务端重算总价、取消限制、门店数据隔离
@@ -72,6 +75,40 @@ class StoreOrderTest extends ProcurementTestCase
$this->assertSame('10.00', (string) $order->total_amount, '应按等级价 5.00×2 计算,忽略前端金额'); $this->assertSame('10.00', (string) $order->total_amount, '应按等级价 5.00×2 计算,忽略前端金额');
} }
/** 下单预填参考重量 = 订货量 × 规格折算(按重量计价的商品订货量即重量),订单总重量 = 明细合计 */
public function test_place_order_prefills_reference_weight(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$byBox = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '5.00',
'spec' => '10斤/箱',
'unit' => '箱',
]);
$byJin = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '5.00',
'spec' => '散装',
'unit' => '斤',
]);
$this->actingAsMiniStore($store);
$this->postJson('/mini/order', [
'items' => [
['product_id' => $byBox->id, 'quantity' => 3],
['product_id' => $byJin->id, 'quantity' => 5],
],
])->assertOk()->assertJsonPath('success', true);
$order = StoreOrderModel::where('store_id', $store->id)->first();
$boxItem = $order->items->firstWhere('product_id', $byBox->id);
$jinItem = $order->items->firstWhere('product_id', $byJin->id);
$this->assertSame('30.000', (string) $boxItem->weight, '3 箱 × 10斤/箱');
$this->assertSame('5.000', (string) $jinItem->weight, '按斤计价:订货量即重量');
$this->assertSame('35.000', (string) $order->total_weight, '订单总重量 = 明细参考重量合计');
}
/** 门店绑定的客户等级被删除时拒绝下单 */ /** 门店绑定的客户等级被删除时拒绝下单 */
public function test_store_level_missing_rejected(): void public function test_store_level_missing_rejected(): void
{ {
@@ -392,6 +429,96 @@ class StoreOrderTest extends ProcurementTestCase
$this->assertSame(StoreOrderModel::STATUS_SUMMARIZED, $pending1->refresh()->status); $this->assertSame(StoreOrderModel::STATUS_SUMMARIZED, $pending1->refresh()->status);
} }
/** 写入下单时段业务配置并刷新缓存(value 传 null 表示该项不配置) */
private function setOrderTimeWindow(?string $start, ?string $end): void
{
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services', 'remark' => '']);
foreach (['order_time_start' => $start, 'order_time_end' => $end] as $key => $value) {
if ($value === null) {
continue;
}
SysSiteConfigItemsModel::create([
'group_id' => $group->id,
'key' => $key,
'title' => $key,
'describe' => '',
'values' => $value,
'type' => 'Input',
'options' => '',
'sort' => 0,
]);
}
SysSiteConfigService::refreshSiteConfig();
}
/** 截单时间:下单时段外禁止下单,时段内正常下单 */
public function test_place_order_blocked_outside_order_time_window(): void
{
[$store, $product] = $this->makeStoreWithProduct('5.00');
$this->setOrderTimeWindow('08:00', '18:00');
$this->actingAsMiniStore($store);
// 20:00 已截单 → 拒绝且不生成订单
$this->travelTo('2026-09-05 20:00:00');
$response = $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
$response->assertOk()->assertJsonPath('success', false);
$this->assertStringContainsString('不在下单时段内', (string) $response->json('msg'));
$this->assertStringContainsString('08:00 - 18:00', (string) $response->json('msg'));
$this->assertSame(0, StoreOrderModel::count());
// 边界:08:00 开始时间与 18:00 截单时间均可下单
$this->travelTo('2026-09-05 08:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$this->travelTo('2026-09-05 18:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$this->assertSame(2, StoreOrderModel::count());
}
/** 截单时间:跨天时段(开始时间晚于截单时间,如 20:00-次日06:00 */
public function test_place_order_cross_midnight_time_window(): void
{
[$store, $product] = $this->makeStoreWithProduct('5.00');
$this->setOrderTimeWindow('20:00', '06:00');
$this->actingAsMiniStore($store);
// 当晚 22:00 与 次日 05:00 均在时段内
$this->travelTo('2026-09-05 22:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$this->travelTo('2026-09-06 05:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
// 中午 12:00 不在时段内 → 拒绝
$this->travelTo('2026-09-06 12:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', false);
$this->assertSame(2, StoreOrderModel::count());
}
/** 截单时间:只配置截单时间时按单边限制(截止后不能下单);格式非法按未配置处理 */
public function test_place_order_only_cutoff_time_and_invalid_config(): void
{
[$store, $product] = $this->makeStoreWithProduct('5.00');
$this->setOrderTimeWindow(null, '18:00');
$this->actingAsMiniStore($store);
$this->travelTo('2026-09-05 17:59:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$this->travelTo('2026-09-05 18:01:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', false);
// 非法格式不拦截(避免误配置导致全天无法下单)
SysSiteConfigItemsModel::query()->update(['values' => 'abc']);
SysSiteConfigService::refreshSiteConfig();
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
}
/** 订单列表商品预览:附带首图/规格/单位供小程序端展示 */ /** 订单列表商品预览:附带首图/规格/单位供小程序端展示 */
public function test_order_list_preview_includes_image_spec_and_unit(): void public function test_order_list_preview_includes_image_spec_and_unit(): void
{ {
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Tests\Unit;
use App\Services\WeightEstimator;
use PHPUnit\Framework\TestCase;
/**
* 参考重量估算:重量单位直算、规格解析折算、无法解析兜底(单位:斤)
*/
class WeightEstimatorTest extends TestCase
{
/** 计价单位本身是重量单位:订货量即重量(忽略规格) */
public function test_weight_unit_quantity_is_weight(): void
{
$this->assertSame('5.000', WeightEstimator::estimate('10斤/箱', '斤', '5'));
$this->assertSame('6.000', WeightEstimator::estimate('', '公斤', '3'));
$this->assertSame('6.000', WeightEstimator::estimate('', 'KG', '3'));
$this->assertSame('1.000', WeightEstimator::estimate('', '克', '500'));
}
/** 规格解析每件重量 × 订货量 */
public function test_spec_per_unit_weight(): void
{
$this->assertSame('30.000', WeightEstimator::estimate('10斤/箱', '箱', '3'));
$this->assertSame('75.000', WeightEstimator::estimate('25斤/袋', '袋', '3'));
$this->assertSame('3.000', WeightEstimator::estimate('500g/袋', '袋', '3'));
$this->assertSame('3.000', WeightEstimator::estimate('500克/袋', '袋', '3'));
$this->assertSame('9.000', WeightEstimator::estimate('1.5kg/箱', '箱', '3'));
$this->assertSame('12.000', WeightEstimator::estimate('2公斤/筐', '筐', '3'));
$this->assertSame('15.000', WeightEstimator::estimate('10斤/箱', '箱', '1.5'), '订货量可为小数');
}
/** 规格无法解析:裸数字按斤计,无数字计 0 */
public function test_unparsable_spec_fallback(): void
{
$this->assertSame('20.000', WeightEstimator::estimate('10/箱', '箱', '2'), '裸数字按斤计');
$this->assertSame('0.000', WeightEstimator::estimate('散装', '箱', '3'));
$this->assertSame('0.000', WeightEstimator::estimate('', '箱', '3'));
}
}
+17
View File
@@ -73,6 +73,23 @@ export async function getPurchaseCell(purchaseId: number, productId: number, sto
}); });
} }
/** 切换单品「已对账」标记(入库持久化,再次调用取消标记) */
export async function togglePurchaseItemCheck(purchaseId: number, productId: number) {
return createAxios<{ checked: boolean }>({
url: `/purchase/order/${purchaseId}/check/${productId}`,
method: 'put',
});
}
/** 批量设置单品「已对账」标记(全选:checked=true 批量标记,false 批量取消) */
export async function batchPurchaseItemCheck(purchaseId: number, productIds: number[], checked: boolean) {
return createAxios<{ count: number }>({
url: `/purchase/order/${purchaseId}/check`,
method: 'put',
data: { product_ids: productIds, checked },
});
}
/** 门店订单明细修改(订货量、重量、单价),自动重算价格 */ /** 门店订单明细修改(订货量、重量、单价),自动重算价格 */
export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellUpdateParams) { export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellUpdateParams) {
return createAxios({ return createAxios({
+8
View File
@@ -74,6 +74,8 @@ export default interface IPurchaseOrder {
total_weight?: string; total_weight?: string;
estimate_amount?: string; estimate_amount?: string;
actual_amount?: string; actual_amount?: string;
/** 应付商品金额 = Σ 已生成门店账单的商品金额(列表接口附;未生成账单为 null) */
bill_product_amount?: string | null;
operator_id?: number; operator_id?: number;
operator?: { id: number; nickname: string }; operator?: { id: number; nickname: string };
remark?: string; remark?: string;
@@ -87,6 +89,8 @@ export interface IPurchaseDetail {
items: IPurchaseDetailRow[]; items: IPurchaseDetailRow[];
/** 门店账单(采购单完成后按门店生成) */ /** 门店账单(采购单完成后按门店生成) */
bills: IBill[]; bills: IBill[];
/** 单品「已对账」标记的商品ID列表(入库持久化) */
checked_product_ids: number[];
} }
/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */ /** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */
@@ -143,6 +147,10 @@ export const BILL_STATUS_MAP: Record<number, { text: string; color: string }> =
export interface IBillPrepareStore { export interface IBillPrepareStore {
store_id: number; store_id: number;
store_name: string; store_name: string;
/** 客户等级名(无等级为 null) */
level_name?: string | null;
/** 客户等级上浮比例(售后金额折算用;无等级为 '0' */
level_percent?: string;
order_count: number; order_count: number;
/** 商品金额(订单汇总,不可修改) */ /** 商品金额(订单汇总,不可修改) */
product_amount: string; product_amount: string;
+22 -21
View File
@@ -294,13 +294,30 @@ const ProductGoodsPage: React.FC = () => {
]; ];
const columns: XinTableColumn<IProduct>[] = [ const columns: XinTableColumn<IProduct>[] = [
// {
// title: 'ID',
// dataIndex: 'id',
// hideInForm: true,
// hideInSearch: true,
// width: 70,
// align: 'center',
// },
{ {
title: 'ID', title: '商品名称',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center', align: 'center',
dataIndex: 'name',
valueType: 'text',
colProps: { span: 24 },
required: true,
render: (_, record) => {
return (
<div>
<div className={"mb-1.5"}>{ record.name }</div>
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
</div>
)
},
rules: [{ required: true, message: '请输入商品名称' }],
}, },
{ {
title: '商品图片', title: '商品图片',
@@ -335,22 +352,6 @@ const ProductGoodsPage: React.FC = () => {
hideInSearch: true, hideInSearch: true,
colProps: { span: 24 }, colProps: { span: 24 },
}, },
{
title: '商品名称',
dataIndex: 'name',
valueType: 'text',
colProps: { span: 24 },
required: true,
render: (_, record) => {
return (
<div>
<div className={"mb-1.5"}>{ record.name }</div>
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
</div>
)
},
rules: [{ required: true, message: '请输入商品名称' }],
},
{ {
title: '商品描述', title: '商品描述',
dataIndex: 'remark', dataIndex: 'remark',
+206 -31
View File
@@ -17,9 +17,10 @@ import {
Table, Table,
Tabs, Tabs,
Tag, Tag,
Tooltip,
Typography, Typography,
} from 'antd'; } from 'antd';
import {AccountBookOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons'; import {AccountBookOutlined, CheckOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons';
import type { TableProps } from 'antd'; import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable'; import XinTable from '@/components/XinTable';
import type { import type {
@@ -42,6 +43,7 @@ import type {
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { import {
addPurchaseStoreItem, addPurchaseStoreItem,
batchPurchaseItemCheck,
exportPurchase, exportPurchase,
exportPurchaseStores, exportPurchaseStores,
exportPurchaseSuppliers, exportPurchaseSuppliers,
@@ -50,6 +52,7 @@ import {
getPurchaseDetail, getPurchaseDetail,
getPurchaseStoreSummary, getPurchaseStoreSummary,
removePurchaseStoreItem, removePurchaseStoreItem,
togglePurchaseItemCheck,
type BillGenerateStoreParams, type BillGenerateStoreParams,
type PurchaseRowUpdateParams, type PurchaseRowUpdateParams,
updatePurchaseRow, updatePurchaseRow,
@@ -83,6 +86,10 @@ const calcUnitRefPrice = (total: number, spec: string): number => {
return Number.isFinite(pack) && pack > 0 ? total / pack : total; return Number.isFinite(pack) && pack > 0 ? total / pack : total;
}; };
/** 售后金额按客户等级上浮折算:实收 = 输入 × (100+percent)/100(两位小数截断,与后端 calcLevelPrice 同口径) */
const calcLevelAmount = (amount: number, percent: number): number =>
Math.trunc(amount * (100 + percent)) / 100;
/** /**
* C1 / C2-C3 / C4 * C1 / C2-C3 / C4
* × * ×
@@ -98,6 +105,10 @@ const PurchaseOrderPage: React.FC = () => {
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [completing, setCompleting] = useState(false); const [completing, setCompleting] = useState(false);
// 商品明细:单品「已对账」勾选标记(入库持久化,随详情接口回显)
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set());
const [checkAllSaving, setCheckAllSaving] = useState(false);
// 行内编辑:供应商选项(品名/供应商/包规/单位/成本与各门店订货量直接在表格中编辑) // 行内编辑:供应商选项(品名/供应商/包规/单位/成本与各门店订货量直接在表格中编辑)
const [suppliers, setSuppliers] = useState<ISupplier[]>([]); const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
@@ -202,17 +213,20 @@ const PurchaseOrderPage: React.FC = () => {
const [billSaving, setBillSaving] = useState(false); const [billSaving, setBillSaving] = useState(false);
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>(); const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加 + 售后金额(可正负) // 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;售后金额 = 输入 × 客户等级上浮比例;总额 = 商品 + 配送费 + 附加 + 售后(可正负)
const watchBillStores = Form.useWatch('stores', billForm) ?? []; const watchBillStores = Form.useWatch('stores', billForm) ?? [];
const billPreview = (billPrepare?.stores ?? []).map((row, index) => { const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
const input = watchBillStores[index] ?? {}; const input = watchBillStores[index] ?? {};
const deliveryFee = Number(input.delivery_fee ?? 0); const deliveryFee = Number(input.delivery_fee ?? 0);
const afterSale = Number(input.after_sale ?? 0); const levelPercent = Number(row.level_percent ?? 0);
const afterSaleInput = Number(input.after_sale ?? 0);
const afterSale = calcLevelAmount(afterSaleInput, levelPercent);
const added = const added =
Number(input.box_num ?? 0) * Number(row.box_price) + Number(input.box_num ?? 0) * Number(row.box_price) +
Number(input.tray_num ?? 0) * Number(row.tray_price); Number(input.tray_num ?? 0) * Number(row.tray_price);
return { return {
added, added,
afterSale,
total: Number(row.product_amount) + deliveryFee + added + afterSale, total: Number(row.product_amount) + deliveryFee + added + afterSale,
}; };
}); });
@@ -246,6 +260,87 @@ const PurchaseOrderPage: React.FC = () => {
} }
}, [detailOpen, detailTab, storeId, detail?.purchase.id]); }, [detailOpen, detailTab, storeId, detail?.purchase.id]);
// 切换采购单时从详情接口恢复单品「已对账」标记(入库持久化)
useEffect(() => {
setCheckedItems(new Set(detail?.checked_product_ids ?? []));
}, [detail]);
/** 切换单品「已对账」标记(入库持久化;乐观更新,请求失败回滚本地勾选) */
const toggleItemChecked = async (productId: number) => {
if (!detail) {
return;
}
const willCheck = !checkedItems.has(productId);
setCheckedItems((prev) => {
const next = new Set(prev);
if (willCheck) {
next.add(productId);
} else {
next.delete(productId);
}
return next;
});
try {
await togglePurchaseItemCheck(detail.purchase.id!, productId);
} catch {
// 错误提示由请求封装统一弹出,这里回滚本地勾选
setCheckedItems((prev) => {
const next = new Set(prev);
if (willCheck) {
next.delete(productId);
} else {
next.add(productId);
}
return next;
});
}
};
/** 商品明细当前可见行的商品ID(列筛选联动,全选仅作用于筛选后的数据) */
const visibleProductIds = useMemo(() => summaryItems.map((row) => row.product_id), [summaryItems]);
/** 当前可见行是否全部已标记 */
const allVisibleChecked =
visibleProductIds.length > 0 && visibleProductIds.every((id) => checkedItems.has(id));
/** 全选/取消全选「已对账」标记(仅作用于列筛选后的可见行;乐观更新,请求失败回滚) */
const toggleAllVisibleChecked = async () => {
if (!detail || visibleProductIds.length === 0 || checkAllSaving) {
return;
}
const willCheck = !allVisibleChecked;
setCheckedItems((prev) => {
const next = new Set(prev);
visibleProductIds.forEach((id) => {
if (willCheck) {
next.add(id);
} else {
next.delete(id);
}
});
return next;
});
setCheckAllSaving(true);
try {
await batchPurchaseItemCheck(detail.purchase.id!, visibleProductIds, willCheck);
} catch {
// 错误提示由请求封装统一弹出,这里回滚本地勾选
setCheckedItems((prev) => {
const next = new Set(prev);
visibleProductIds.forEach((id) => {
if (willCheck) {
next.delete(id);
} else {
next.add(id);
}
});
return next;
});
} finally {
setCheckAllSaving(false);
}
};
const loadDetail = async (id: number) => { const loadDetail = async (id: number) => {
setDetailLoading(true); setDetailLoading(true);
try { try {
@@ -403,7 +498,8 @@ const PurchaseOrderPage: React.FC = () => {
const res = await updatePurchaseRow(detail.purchase.id!, row.product_id, params); const res = await updatePurchaseRow(detail.purchase.id!, row.product_id, params);
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`); message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
await loadDetail(detail.purchase.id!); const res1 = await getPurchaseDetail(detail.purchase.id!);
setDetail(res1.data.data ?? null);
await tableRef.current?.reload(); await tableRef.current?.reload();
}; };
@@ -424,8 +520,9 @@ const PurchaseOrderPage: React.FC = () => {
return; return;
} }
await updatePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id, { quantity }); await updatePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id, { quantity });
const res = await getPurchaseDetail(detail.purchase.id!);
setDetail(res.data.data ?? null);
message.success('订货量已更新,订货单与采购单汇总已重算'); message.success('订货量已更新,订货单与采购单汇总已重算');
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload(); await tableRef.current?.reload();
}; };
@@ -562,20 +659,56 @@ const PurchaseOrderPage: React.FC = () => {
render: (_, row) => renderEditableCell(row, 'supplier_id', row.supplier?.name ?? '-'), render: (_, row) => renderEditableCell(row, 'supplier_id', row.supplier?.name ?? '-'),
}, },
{ {
title: '市场', title: (
<Space size={4}>
<span></span>
<Tooltip
title={`${allVisibleChecked ? '取消全选' : '全选对账'}(仅作用于当前筛选显示的 ${visibleProductIds.length} 行)`}
>
<Button
size="small"
shape="circle"
style={{ minWidth: 16, height: 16, width: 16 }}
icon={<CheckOutlined />}
color={allVisibleChecked ? 'green' : 'default'}
variant={allVisibleChecked ? 'solid' : 'outlined'}
loading={checkAllSaving}
disabled={visibleProductIds.length === 0}
onClick={() => void toggleAllVisibleChecked()}
/>
</Tooltip>
</Space>
),
fixed: 'left', fixed: 'left',
dataIndex: 'market', dataIndex: 'market',
width: 110, width: 90,
align: 'center', align: 'center',
filters: itemMarketOptions.map((m) => ({ text: m, value: m })), filters: itemMarketOptions.map((m) => ({ text: m, value: m })),
filteredValue: itemColumnFilters.market ?? null, filteredValue: itemColumnFilters.market ?? null,
onFilter: (value, row) => (row.market ?? '') === value, onFilter: (value, row) => (row.market ?? '') === value,
render: (v) => v || '-', render: (v, row) => {
// ✔ 按钮标记本单品已对账(入库持久化,随详情接口回显)
const checked = checkedItems.has(row.product_id);
return (
<Space size={4}>
<span>{v || '-'}</span>
<Button
size="small"
shape="circle"
style={{ minWidth: 16, height: 16, width: 16 }}
icon={<CheckOutlined />}
color={checked ? 'green' : 'default'}
variant={checked ? 'solid' : 'outlined'}
onClick={() => void toggleItemChecked(row.product_id)}
/>
</Space>
);
},
}, },
{ {
title: '单价', title: '单价',
key: 'retail_price', key: 'retail_price',
width: 110, width: 90,
align: 'center', align: 'center',
render: (_, row) => { render: (_, row) => {
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价) // 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
@@ -589,20 +722,20 @@ const PurchaseOrderPage: React.FC = () => {
title: '包规', title: '包规',
dataIndex: 'product_spec', dataIndex: 'product_spec',
align: 'center', align: 'center',
width: 110, width: 90,
render: (v, row) => renderEditableCell(row, 'product_spec', v || '-'), render: (v, row) => renderEditableCell(row, 'product_spec', v || '-'),
}, },
{ {
title: '单位', title: '单位',
dataIndex: 'unit', dataIndex: 'unit',
width: 110, width: 90,
align: 'center', align: 'center',
render: (v, row) => renderEditableCell(row, 'unit', v || '-'), render: (v, row) => renderEditableCell(row, 'unit', v || '-'),
}, },
{ {
title: '成本', title: '成本',
dataIndex: 'cost_price', dataIndex: 'cost_price',
width: 110, width: 120,
align: 'center', align: 'center',
render: (v, row) => renderEditableCell(row, 'cost_price', `¥${Number(v).toFixed(2)}`), render: (v, row) => renderEditableCell(row, 'cost_price', `¥${Number(v).toFixed(2)}`),
}, },
@@ -633,6 +766,7 @@ const PurchaseOrderPage: React.FC = () => {
key={`${row.product_id}-${store.id}-${quantity}`} key={`${row.product_id}-${store.id}-${quantity}`}
size="small" size="small"
min={0} min={0}
styles={{ input: { textAlign: 'center' }, root: {width: 80} }}
precision={0} precision={0}
className="w-full" className="w-full"
defaultValue={quantity} defaultValue={quantity}
@@ -973,6 +1107,19 @@ const PurchaseOrderPage: React.FC = () => {
<Text type="secondary"></Text> <Text type="secondary"></Text>
), ),
}, },
{
title: '应付商品金额',
dataIndex: 'bill_product_amount',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) =>
record.bill_product_amount != null ? (
<Text strong type="danger">¥{Number(record.bill_product_amount).toFixed(2)}</Text>
) : (
<Text type="secondary"></Text>
),
},
{ {
title: '总件数', title: '总件数',
dataIndex: 'total_quantity', dataIndex: 'total_quantity',
@@ -1275,6 +1422,9 @@ const PurchaseOrderPage: React.FC = () => {
</Button> </Button>
</AuthButton> </AuthButton>
<Text type="secondary" className="text-xs">
{visibleProductIds.filter((id) => checkedItems.has(id)).length}/{visibleProductIds.length}
</Text>
{canUpdateRow && ( {canUpdateRow && (
<Text type="secondary" className="text-xs"> <Text type="secondary" className="text-xs">
@@ -1289,7 +1439,7 @@ const PurchaseOrderPage: React.FC = () => {
dataSource={detail.items} dataSource={detail.items}
onChange={(_pagination, filters) => setItemColumnFilters(filters)} onChange={(_pagination, filters) => setItemColumnFilters(filters)}
pagination={false} pagination={false}
scroll={{ x: 1200, y: 800 }} scroll={{ x: 900 + (detail?.stores.length * 100), y: 500 }}
summary={renderSummary} summary={renderSummary}
/> />
</> </>
@@ -1317,7 +1467,7 @@ const PurchaseOrderPage: React.FC = () => {
{billPrepare && ( {billPrepare && (
<> <>
<div className="py-2 text-gray-500"> <div className="py-2 text-gray-500">
/==== /== 15 1% 15.15
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'} {billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div> </div>
<Form form={billForm} layout="vertical" onFinish={handleBillSave}> <Form form={billForm} layout="vertical" onFinish={handleBillSave}>
@@ -1328,9 +1478,18 @@ const PurchaseOrderPage: React.FC = () => {
<div className="w-40 shrink-0"></div> <div className="w-40 shrink-0"></div>
<div className="w-28 shrink-0 text-center"></div> <div className="w-28 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center"></div> <div className="w-32 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.box_price ?? '0.00'}/</div> <div className="w-32 shrink-0 text-center">
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/</div> <div></div>
<div className="w-36 shrink-0 text-center"></div> <div>¥{billPrepare.stores[0]?.box_price ?? '0.00'}/</div>
</div>
<div className="w-32 shrink-0 text-center">
<div></div>
<div>¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/</div>
</div>
<div className="w-50 shrink-0 text-center">
<div></div>
<div></div>
</div>
<div className="w-36 shrink-0 text-center"></div> <div className="w-36 shrink-0 text-center"></div>
<div className="w-28 shrink-0 text-center"></div> <div className="w-28 shrink-0 text-center"></div>
<div className="flex-1 text-center"></div> <div className="flex-1 text-center"></div>
@@ -1346,6 +1505,11 @@ const PurchaseOrderPage: React.FC = () => {
{row?.order_count ?? 0} {row?.order_count ?? 0}
{billed && <Tag className="ml-1!" color="success"></Tag>} {billed && <Tag className="ml-1!" color="success"></Tag>}
</div> </div>
<div className="text-xs text-gray-400">
{row?.level_name
? `${row.level_name} +${Number(row.level_percent ?? 0)}%`
: '无等级(不上浮)'}
</div>
</div> </div>
<div className="w-28 shrink-0 text-center"> <div className="w-28 shrink-0 text-center">
<Text strong>¥{row?.product_amount ?? '0.00'}</Text> <Text strong>¥{row?.product_amount ?? '0.00'}</Text>
@@ -1354,7 +1518,7 @@ const PurchaseOrderPage: React.FC = () => {
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
className="m-0! w-32 shrink-0 px-1!" className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'delivery_fee']} name={[field.name, 'delivery_fee']}
rules={[{ required: true, message: '请输入配送费' }]} rules={[{ required: true, message: '请输入配送费' }]}
> >
@@ -1367,7 +1531,7 @@ const PurchaseOrderPage: React.FC = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
className="m-0! w-32 shrink-0 px-1!" className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'box_num']} name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转筐数量' }]} rules={[{ required: true, message: '请输入周转筐数量' }]}
> >
@@ -1379,7 +1543,7 @@ const PurchaseOrderPage: React.FC = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
className="m-0! w-32 shrink-0 px-1!" className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'tray_num']} name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]} rules={[{ required: true, message: '请输入周转托盘数量' }]}
> >
@@ -1390,17 +1554,28 @@ const PurchaseOrderPage: React.FC = () => {
placeholder="正压负回" placeholder="正压负回"
/> />
</Form.Item> </Form.Item>
<Form.Item <div className="w-50 shrink-0 px-1! flex justify-center">
className="m-0! w-36 shrink-0 px-1!" <Space.Compact block>
name={[field.name, 'after_sale']} <Space.Addon>
> {/* 售后金额按客户等级上浮折算预览(实收 = 输入 × (100+上浮%)/100);已生成账单行为存档值不再折算 */}
<InputNumber {(
className="w-full" <div className="text-xs text-orange-500 text-center">
precision={2} ¥{billPreview[field.name]?.afterSale.toFixed(2)}
disabled={billed} </div>
placeholder="可正负,计入总额" )}
/> </Space.Addon>
</Form.Item> <Form.Item className="m-0!" name={[field.name, 'after_sale']}>
<InputNumber
className="w-full"
precision={2}
prefix={'¥'}
disabled={billed}
placeholder="可正负,计入总额"
/>
</Form.Item>
</Space.Compact>
</div>
<Form.Item <Form.Item
className="m-0! w-36 shrink-0 px-1!" className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'remark']} name={[field.name, 'remark']}