Compare commits

...

11 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
25 changed files with 718 additions and 62 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();
$rows[] = ['账单号:' . $billNoText];
$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';
// 空行
+1 -1
View File
@@ -92,7 +92,7 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
$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';
// 空行
+17 -7
View File
@@ -22,7 +22,8 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色;
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏)
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏)
* 市场/数量/门店列列宽减半、列头自动换行;门店数量无数据显示空白(不显示 0)
*/
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
{
@@ -183,7 +184,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
if ($this->supplierId > 0) {
$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';
// 空行
@@ -222,7 +223,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$item['quantity'],
$item['weight'],
$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);
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
@@ -231,10 +235,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
}
}
// 合计行(行合计 + 门店列合计)
// 合计行(行合计 + 门店列合计;门店无数据显示空白
$rows[] = array_merge(
['', '', '合计', '', '', '', '', '', '', (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->lastRow = $rowIndex;
@@ -256,7 +260,8 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$this->collection();
$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) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
@@ -265,7 +270,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$storeCount = count($storeIds);
$lastColumn = Coordinate::stringFromColumnIndex(12 + max($storeCount, 1));
foreach ($storeIds as $i => $storeId) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(12);
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(6);
}
// 默认隐藏列:序号/分类/包规/单位/成本/单价/实际称重/金额(数据照常导出,Excel 中可取消隐藏)
@@ -279,6 +284,11 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
->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()
+27 -7
View File
@@ -61,7 +61,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
}
/**
* 导出行:标题/空行/列头(品名、汇总、市场、各门店)/明细
* 导出行:标题+备注(合并区)/空行(合并区)/列头(品名、汇总、市场、各门店)/明细
*/
public function collection(): Collection
{
@@ -74,11 +74,16 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$rows = [];
$rowIndex = 0;
// 标题行
$rows[] = [$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no];
// 标题行(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++;
@@ -117,7 +122,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
}
/**
* 标题/列头加粗;全表居中 + 全边框;
* 标题合并 A1:C2、备注合并 D1:I2(红色 22 号字);标题/列头加粗;全表居中 + 全边框 + 列头行自动换行
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
@@ -126,14 +131,18 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$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) {
$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(12);
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(6);
}
// 全部单元格水平/垂直居中
@@ -142,6 +151,14 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
->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()
@@ -179,6 +196,9 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
}
}
// 备注单元格(D1):红色 22 号字,须放在行样式之后应用以覆盖标题行字号
$styles['D1'] = ['font' => ['bold' => true, 'size' => 22, 'color' => ['argb' => 'FFFF0000']]];
return $styles;
}
@@ -13,6 +13,7 @@ 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;
@@ -54,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)
@@ -173,6 +177,12 @@ class PurchaseOrderController extends BaseController
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(),
]);
}
@@ -613,8 +623,9 @@ class PurchaseOrderController extends BaseController
->get(['id', 'store_id', 'total_amount']);
$stores = StoreModel::withTrashed()
->with('level:id,name,percent')
->whereIn('id', $orders->pluck('store_id')->unique())
->get(['id', 'name'])
->get(['id', 'name', 'level_id'])
->keyBy('id');
$bills = BillModel::query()
@@ -632,9 +643,13 @@ class PurchaseOrderController extends BaseController
'0'
);
$bill = $bills->get((int) $storeId);
$store = $stores->get((int) $storeId);
$rows[] = [
'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(),
'product_amount' => $productAmount,
'box_price' => $boxPrice,
@@ -795,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',
'operator_id' => 'integer',
'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');
}
/**
* 本采购单生成的门店账单
*/
public function bills(): HasMany
{
return $this->hasMany(BillModel::class, 'purchase_id', 'id');
}
}
+2
View File
@@ -85,6 +85,8 @@ class BillDetailService
'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),
+16 -2
View File
@@ -5,6 +5,7 @@ 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;
@@ -20,7 +21,8 @@ use Throwable;
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
* 4. 售后金额 = 按门店填写的调整金额(可正负:正数=加收,负数=售后减免)
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
* 可正负:正数=加收,负数=售后减免)
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
*/
@@ -82,6 +84,14 @@ readonly class BillGenerateService
$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];
@@ -96,7 +106,11 @@ readonly class BillGenerateService
bcmul((string) (int) $row['tray_num'], $trayPrice, 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);
$bill = BillModel::create([
@@ -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');
}
};
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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XinAdmin</title>
<script type="module" crossorigin src="/assets/index-wuX9SPpH.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/jsx-runtime-CRBytmvs.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/dict-CDRllPHM.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>
<body>
<div id="root"></div>
+50
View File
@@ -133,6 +133,56 @@ class BillPaymentTest extends ProcurementTestCase
$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
{
+158
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\PurchaseItemCheckModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
@@ -607,4 +608,161 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变');
$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');
}
}
+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) {
return createAxios({
+8
View File
@@ -74,6 +74,8 @@ export default interface IPurchaseOrder {
total_weight?: string;
estimate_amount?: string;
actual_amount?: string;
/** 应付商品金额 = Σ 已生成门店账单的商品金额(列表接口附;未生成账单为 null) */
bill_product_amount?: string | null;
operator_id?: number;
operator?: { id: number; nickname: string };
remark?: string;
@@ -87,6 +89,8 @@ export interface IPurchaseDetail {
items: IPurchaseDetailRow[];
/** 门店账单(采购单完成后按门店生成) */
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 {
store_id: number;
store_name: string;
/** 客户等级名(无等级为 null) */
level_name?: string | null;
/** 客户等级上浮比例(售后金额折算用;无等级为 '0' */
level_percent?: string;
order_count: number;
/** 商品金额(订单汇总,不可修改) */
product_amount: string;
+199 -24
View File
@@ -17,9 +17,10 @@ import {
Table,
Tabs,
Tag,
Tooltip,
Typography,
} 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 XinTable from '@/components/XinTable';
import type {
@@ -42,6 +43,7 @@ import type {
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import {
addPurchaseStoreItem,
batchPurchaseItemCheck,
exportPurchase,
exportPurchaseStores,
exportPurchaseSuppliers,
@@ -50,6 +52,7 @@ import {
getPurchaseDetail,
getPurchaseStoreSummary,
removePurchaseStoreItem,
togglePurchaseItemCheck,
type BillGenerateStoreParams,
type PurchaseRowUpdateParams,
updatePurchaseRow,
@@ -83,6 +86,10 @@ const calcUnitRefPrice = (total: number, spec: string): number => {
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 明细矩阵修改)
* 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列
@@ -98,6 +105,10 @@ const PurchaseOrderPage: React.FC = () => {
const [detailLoading, setDetailLoading] = 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[]>([]);
@@ -202,17 +213,20 @@ const PurchaseOrderPage: React.FC = () => {
const [billSaving, setBillSaving] = useState(false);
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加 + 售后金额(可正负)
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;售后金额 = 输入 × 客户等级上浮比例;总额 = 商品 + 配送费 + 附加 + 售后(可正负)
const watchBillStores = Form.useWatch('stores', billForm) ?? [];
const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
const input = watchBillStores[index] ?? {};
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 =
Number(input.box_num ?? 0) * Number(row.box_price) +
Number(input.tray_num ?? 0) * Number(row.tray_price);
return {
added,
afterSale,
total: Number(row.product_amount) + deliveryFee + added + afterSale,
};
});
@@ -246,6 +260,87 @@ const PurchaseOrderPage: React.FC = () => {
}
}, [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) => {
setDetailLoading(true);
try {
@@ -403,7 +498,8 @@ const PurchaseOrderPage: React.FC = () => {
const res = await updatePurchaseRow(detail.purchase.id!, row.product_id, params);
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();
};
@@ -424,8 +520,9 @@ const PurchaseOrderPage: React.FC = () => {
return;
}
await updatePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id, { quantity });
const res = await getPurchaseDetail(detail.purchase.id!);
setDetail(res.data.data ?? null);
message.success('订货量已更新,订货单与采购单汇总已重算');
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
};
@@ -562,20 +659,56 @@ const PurchaseOrderPage: React.FC = () => {
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',
dataIndex: 'market',
width: 110,
width: 90,
align: 'center',
filters: itemMarketOptions.map((m) => ({ text: m, value: m })),
filteredValue: itemColumnFilters.market ?? null,
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: '单价',
key: 'retail_price',
width: 110,
width: 90,
align: 'center',
render: (_, row) => {
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
@@ -589,20 +722,20 @@ const PurchaseOrderPage: React.FC = () => {
title: '包规',
dataIndex: 'product_spec',
align: 'center',
width: 110,
width: 90,
render: (v, row) => renderEditableCell(row, 'product_spec', v || '-'),
},
{
title: '单位',
dataIndex: 'unit',
width: 110,
width: 90,
align: 'center',
render: (v, row) => renderEditableCell(row, 'unit', v || '-'),
},
{
title: '成本',
dataIndex: 'cost_price',
width: 110,
width: 120,
align: 'center',
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}`}
size="small"
min={0}
styles={{ input: { textAlign: 'center' }, root: {width: 80} }}
precision={0}
className="w-full"
defaultValue={quantity}
@@ -973,6 +1107,19 @@ const PurchaseOrderPage: React.FC = () => {
<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: '总件数',
dataIndex: 'total_quantity',
@@ -1275,6 +1422,9 @@ const PurchaseOrderPage: React.FC = () => {
</Button>
</AuthButton>
<Text type="secondary" className="text-xs">
{visibleProductIds.filter((id) => checkedItems.has(id)).length}/{visibleProductIds.length}
</Text>
{canUpdateRow && (
<Text type="secondary" className="text-xs">
@@ -1289,7 +1439,7 @@ const PurchaseOrderPage: React.FC = () => {
dataSource={detail.items}
onChange={(_pagination, filters) => setItemColumnFilters(filters)}
pagination={false}
scroll={{ x: 1200, y: 800 }}
scroll={{ x: 900 + (detail?.stores.length * 100), y: 500 }}
summary={renderSummary}
/>
</>
@@ -1317,7 +1467,7 @@ const PurchaseOrderPage: React.FC = () => {
{billPrepare && (
<>
<div className="py-2 text-gray-500">
/====
/== 15 1% 15.15
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div>
<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-28 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">¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/</div>
<div className="w-36 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center">
<div></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-28 shrink-0 text-center"></div>
<div className="flex-1 text-center"></div>
@@ -1346,6 +1505,11 @@ const PurchaseOrderPage: React.FC = () => {
{row?.order_count ?? 0}
{billed && <Tag className="ml-1!" color="success"></Tag>}
</div>
<div className="text-xs text-gray-400">
{row?.level_name
? `${row.level_name} +${Number(row.level_percent ?? 0)}%`
: '无等级(不上浮)'}
</div>
</div>
<div className="w-28 shrink-0 text-center">
<Text strong>¥{row?.product_amount ?? '0.00'}</Text>
@@ -1354,7 +1518,7 @@ const PurchaseOrderPage: React.FC = () => {
<Input />
</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']}
rules={[{ required: true, message: '请输入配送费' }]}
>
@@ -1367,7 +1531,7 @@ const PurchaseOrderPage: React.FC = () => {
/>
</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']}
rules={[{ required: true, message: '请输入周转筐数量' }]}
>
@@ -1379,7 +1543,7 @@ const PurchaseOrderPage: React.FC = () => {
/>
</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']}
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
@@ -1390,17 +1554,28 @@ const PurchaseOrderPage: React.FC = () => {
placeholder="正压负回"
/>
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'after_sale']}
>
<div className="w-50 shrink-0 px-1! flex justify-center">
<Space.Compact block>
<Space.Addon>
{/* 售后金额按客户等级上浮折算预览(实收 = 输入 × (100+上浮%)/100);已生成账单行为存档值不再折算 */}
{(
<div className="text-xs text-orange-500 text-center">
¥{billPreview[field.name]?.afterSale.toFixed(2)}
</div>
)}
</Space.Addon>
<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
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'remark']}