批量对账

This commit is contained in:
liu
2026-09-07 22:53:50 +08:00
parent 379aeac2b3
commit 7de51391e4
9 changed files with 469 additions and 39 deletions
File diff suppressed because one or more lines are too long
+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;
@@ -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()], '已标记为已对账');
}
/**
* 采购单编辑闸:仅进行中(待采购)允许修改明细
*/
+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');
}
}
@@ -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');
}
};
+134
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,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());
}
}
+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({
+2
View File
@@ -87,6 +87,8 @@ export interface IPurchaseDetail {
items: IPurchaseDetailRow[];
/** 门店账单(采购单完成后按门店生成) */
bills: IBill[];
/** 单品「已对账」标记的商品ID列表(入库持久化) */
checked_product_ids: number[];
}
/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */
+94 -21
View File
@@ -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">