批量对账
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
@@ -173,6 +174,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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -795,6 +802,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()], '已标记为已对账');
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购单编辑闸:仅进行中(待采购)允许修改明细
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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,137 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface IPurchaseDetail {
|
||||
items: IPurchaseDetailRow[];
|
||||
/** 门店账单(采购单完成后按门店生成) */
|
||||
bills: IBill[];
|
||||
/** 单品「已对账」标记的商品ID列表(入库持久化) */
|
||||
checked_product_ids: number[];
|
||||
}
|
||||
|
||||
/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
import {
|
||||
addPurchaseStoreItem,
|
||||
batchPurchaseItemCheck,
|
||||
exportPurchase,
|
||||
exportPurchaseStores,
|
||||
exportPurchaseSuppliers,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
getPurchaseDetail,
|
||||
getPurchaseStoreSummary,
|
||||
removePurchaseStoreItem,
|
||||
togglePurchaseItemCheck,
|
||||
type BillGenerateStoreParams,
|
||||
type PurchaseRowUpdateParams,
|
||||
updatePurchaseRow,
|
||||
@@ -99,8 +101,9 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [completing, setCompleting] = useState(false);
|
||||
|
||||
// 商品明细:单品「已对账」勾选标记(前端本地控制不入库,按采购单持久化到 localStorage)
|
||||
// 商品明细:单品「已对账」勾选标记(入库持久化,随详情接口回显)
|
||||
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set());
|
||||
const [checkAllSaving, setCheckAllSaving] = useState(false);
|
||||
|
||||
// 行内编辑:供应商选项(品名/供应商/包规/单位/成本与各门店订货量直接在表格中编辑)
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
@@ -250,35 +253,85 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
}
|
||||
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
|
||||
|
||||
// 切换采购单时从 localStorage 恢复单品「已对账」标记
|
||||
// 切换采购单时从详情接口恢复单品「已对账」标记(入库持久化)
|
||||
useEffect(() => {
|
||||
if (!detail) {
|
||||
setCheckedItems(new Set());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(`purchase-checked-items:${detail.purchase.id}`);
|
||||
setCheckedItems(new Set(raw ? (JSON.parse(raw) as number[]) : []));
|
||||
} catch {
|
||||
setCheckedItems(new Set());
|
||||
}
|
||||
setCheckedItems(new Set(detail?.checked_product_ids ?? []));
|
||||
}, [detail]);
|
||||
|
||||
/** 切换单品「已对账」标记(前端本地控制,不入库) */
|
||||
const toggleItemChecked = (productId: number) => {
|
||||
/** 切换单品「已对账」标记(入库持久化;乐观更新,请求失败回滚本地勾选) */
|
||||
const toggleItemChecked = async (productId: number) => {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
const willCheck = !checkedItems.has(productId);
|
||||
setCheckedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(productId)) {
|
||||
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);
|
||||
}
|
||||
localStorage.setItem(`purchase-checked-items:${detail.purchase.id}`, JSON.stringify([...next]));
|
||||
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) => {
|
||||
@@ -599,7 +652,26 @@ 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: 90,
|
||||
@@ -608,12 +680,11 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
filteredValue: itemColumnFilters.market ?? null,
|
||||
onFilter: (value, row) => (row.market ?? '') === value,
|
||||
render: (v, row) => {
|
||||
// ✔ 按钮标记本单品已对账(前端本地标记,不入库)
|
||||
// ✔ 按钮标记本单品已对账(入库持久化,随详情接口回显)
|
||||
const checked = checkedItems.has(row.product_id);
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span>{v || '-'}</span>
|
||||
<Tooltip title={checked ? '取消对账标记' : '标记本单品已对账'}>
|
||||
<Button
|
||||
size="small"
|
||||
shape="circle"
|
||||
@@ -621,9 +692,8 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
icon={<CheckOutlined />}
|
||||
color={checked ? 'green' : 'default'}
|
||||
variant={checked ? 'solid' : 'outlined'}
|
||||
onClick={() => toggleItemChecked(row.product_id)}
|
||||
onClick={() => void toggleItemChecked(row.product_id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
@@ -1332,6 +1402,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">
|
||||
修改保存将同步该商品全部订货明细与商品档案,成本变化时各门店单价按等级上浮自动重算
|
||||
|
||||
Reference in New Issue
Block a user