采购单优化
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -238,7 +238,8 @@ class PurchaseOrderController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C4 商品行修改:采购成本同步该商品全部订货明细;实际称重按数量比例分摊(尾差修正守恒)
|
* C4 商品行修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细;
|
||||||
|
* 实际称重按数量比例分摊(尾差修正守恒)
|
||||||
*/
|
*/
|
||||||
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
||||||
public function updateRow(int $id, int $productId, PurchaseRowUpdateRequest $request): JsonResponse
|
public function updateRow(int $id, int $productId, PurchaseRowUpdateRequest $request): JsonResponse
|
||||||
@@ -248,13 +249,25 @@ class PurchaseOrderController extends BaseController
|
|||||||
throw new RepositoryException('采购单不存在');
|
throw new RepositoryException('采购单不存在');
|
||||||
}
|
}
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$costPrice = isset($validated['cost_price']) ? (float) $validated['cost_price'] : null;
|
|
||||||
|
$attrs = [];
|
||||||
|
foreach (['product_name', 'supplier_id', 'product_spec', 'unit', 'cost_price'] as $field) {
|
||||||
|
if (isset($validated[$field])) {
|
||||||
|
$attrs[$field] = $validated[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
$weight = isset($validated['weight']) ? (float) $validated['weight'] : null;
|
$weight = isset($validated['weight']) ? (float) $validated['weight'] : null;
|
||||||
if ($costPrice === null && $weight === null) {
|
if ($attrs === [] && $weight === null) {
|
||||||
throw new RepositoryException('采购成本与实际称重至少填写一项');
|
throw new RepositoryException('品名/供应商/包规/单位/成本/实际称重至少填写一项');
|
||||||
}
|
}
|
||||||
|
|
||||||
$count = app(PurchaseEditService::class)->updateRow($purchase, $productId, $costPrice, $weight);
|
$count = app(PurchaseEditService::class)->updateRow(
|
||||||
|
$purchase,
|
||||||
|
$productId,
|
||||||
|
$attrs,
|
||||||
|
$weight,
|
||||||
|
(bool) ($validated['sync_product'] ?? false),
|
||||||
|
);
|
||||||
|
|
||||||
return $this->success(['count' => $count], '已同步 ' . $count . ' 条订货明细');
|
return $this->success(['count' => $count], '已同步 ' . $count . ' 条订货明细');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ namespace App\Http\Requests\Purchase;
|
|||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购明细商品行修改 验证(C4;成本/称重至少填一项,同步到该商品全部订货明细)
|
* 采购明细商品行修改 验证(C4;品名/供应商/包规/单位/成本/称重至少一项,
|
||||||
|
* 提交后一键同步该商品在本采购单下的全部订货明细)
|
||||||
*/
|
*/
|
||||||
class PurchaseRowUpdateRequest extends BaseFormRequest
|
class PurchaseRowUpdateRequest extends BaseFormRequest
|
||||||
{
|
{
|
||||||
@@ -14,14 +15,23 @@ class PurchaseRowUpdateRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'product_name' => 'nullable|string|max:100',
|
||||||
|
'supplier_id' => 'nullable|integer|exists:supplier,id',
|
||||||
|
'product_spec' => 'nullable|string|max:100',
|
||||||
|
'unit' => 'nullable|string|max:20',
|
||||||
'cost_price' => 'nullable|numeric|min:0',
|
'cost_price' => 'nullable|numeric|min:0',
|
||||||
'weight' => 'nullable|numeric|min:0',
|
'weight' => 'nullable|numeric|min:0',
|
||||||
|
'sync_product' => 'nullable|boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'product_name.max' => '品名不能超过 100 字',
|
||||||
|
'supplier_id.exists' => '供应商不存在',
|
||||||
|
'product_spec.max' => '包规不能超过 100 字',
|
||||||
|
'unit.max' => '单位不能超过 20 字',
|
||||||
'cost_price.numeric' => '采购成本必须为数字',
|
'cost_price.numeric' => '采购成本必须为数字',
|
||||||
'cost_price.min' => '采购成本不能小于 0',
|
'cost_price.min' => '采购成本不能小于 0',
|
||||||
'weight.numeric' => '实际称重必须为数字',
|
'weight.numeric' => '实际称重必须为数字',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Models\ProductModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
@@ -46,14 +47,16 @@ class PurchaseEditService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品行修改:采购成本(写入该商品全部订货明细)/ 实际称重(按数量比例分摊,尾差修正守恒)
|
* 商品行修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细;
|
||||||
|
* 实际称重按数量比例分摊(尾差修正守恒);
|
||||||
|
* syncProduct = true 时同步更新商品档案(product 表)
|
||||||
*
|
*
|
||||||
* @param float|null $costPrice 采购成本(每包规),NULL 不修改
|
* @param array{product_name?: string, supplier_id?: int, product_spec?: string, unit?: string, cost_price?: float} $attrs 行属性(仅同步传入键)
|
||||||
* @param float|null $weight 行实际称重合计,NULL 不修改
|
* @param float|null $weight 行实际称重合计,NULL 不修改
|
||||||
*/
|
*/
|
||||||
public function updateRow(PurchaseOrderModel $purchase, int $productId, ?float $costPrice, ?float $weight): int
|
public function updateRow(PurchaseOrderModel $purchase, int $productId, array $attrs, ?float $weight, bool $syncProduct = false): int
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($purchase, $productId, $costPrice, $weight) {
|
return DB::transaction(function () use ($purchase, $productId, $attrs, $weight, $syncProduct) {
|
||||||
$items = StoreOrderItemModel::query()
|
$items = StoreOrderItemModel::query()
|
||||||
->where('purchase_id', $purchase->id)
|
->where('purchase_id', $purchase->id)
|
||||||
->where('product_id', $productId)
|
->where('product_id', $productId)
|
||||||
@@ -63,11 +66,50 @@ class PurchaseEditService
|
|||||||
throw new RepositoryException('该采购单下无此商品的订货明细');
|
throw new RepositoryException('该采购单下无此商品的订货明细');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($costPrice !== null) {
|
$sync = [];
|
||||||
$cost = bcadd((string) $costPrice, '0', 2);
|
if (isset($attrs['product_name'])) {
|
||||||
|
$sync['product_name'] = $attrs['product_name'];
|
||||||
|
}
|
||||||
|
if (isset($attrs['supplier_id'])) {
|
||||||
|
$sync['supplier_id'] = (int) $attrs['supplier_id'];
|
||||||
|
}
|
||||||
|
if (isset($attrs['product_spec'])) {
|
||||||
|
$sync['product_spec'] = $attrs['product_spec'];
|
||||||
|
}
|
||||||
|
if (isset($attrs['unit'])) {
|
||||||
|
$sync['unit'] = $attrs['unit'];
|
||||||
|
}
|
||||||
|
if (isset($attrs['cost_price'])) {
|
||||||
|
$sync['cost_price'] = bcadd((string) $attrs['cost_price'], '0', 2);
|
||||||
|
}
|
||||||
|
if ($sync !== []) {
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$item->cost_price = $cost;
|
$item->fill($sync)->save();
|
||||||
$item->save();
|
}
|
||||||
|
|
||||||
|
// 同步至商品档案(软删除商品不同步,直接报错回滚)
|
||||||
|
if ($syncProduct) {
|
||||||
|
$product = ProductModel::withTrashed()->find($productId);
|
||||||
|
if ($product === null || $product->trashed()) {
|
||||||
|
throw new RepositoryException('商品档案不存在或已删除,无法同步至商品');
|
||||||
|
}
|
||||||
|
$productAttrs = [];
|
||||||
|
if (isset($sync['product_name'])) {
|
||||||
|
$productAttrs['name'] = $sync['product_name'];
|
||||||
|
}
|
||||||
|
if (isset($sync['supplier_id'])) {
|
||||||
|
$productAttrs['supplier_id'] = $sync['supplier_id'];
|
||||||
|
}
|
||||||
|
if (isset($sync['product_spec'])) {
|
||||||
|
$productAttrs['spec'] = $sync['product_spec'];
|
||||||
|
}
|
||||||
|
if (isset($sync['unit'])) {
|
||||||
|
$productAttrs['unit'] = $sync['unit'];
|
||||||
|
}
|
||||||
|
if (isset($sync['cost_price'])) {
|
||||||
|
$productAttrs['cost_price'] = $sync['cost_price'];
|
||||||
|
}
|
||||||
|
$product->fill($productAttrs)->save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ return new class extends Migration
|
|||||||
$table->string('address', 255)->default('')->comment('门店地址');
|
$table->string('address', 255)->default('')->comment('门店地址');
|
||||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
||||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||||
$table->string('remark', 255)->default('')->comment('备注');
|
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
$table->index(['level_id', 'status'], 'store_level_status_index');
|
$table->index(['level_id', 'status'], 'store_level_status_index');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\PurchaseOrderModel;
|
|||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
|
use App\Models\SupplierModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +52,7 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
|
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 详情返回「商品行 × 门店列」矩阵:单价 = 成本/包规,单元格金额 = 数量×单价 */
|
/** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
|
||||||
public function test_detail_returns_store_matrix(): void
|
public function test_detail_returns_store_matrix(): void
|
||||||
{
|
{
|
||||||
[$purchase, $product, $stores] = $this->buildPurchase();
|
[$purchase, $product, $stores] = $this->buildPurchase();
|
||||||
@@ -69,16 +70,12 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
$this->assertSame($product->id, $row['product_id']);
|
$this->assertSame($product->id, $row['product_id']);
|
||||||
$this->assertSame('10斤/箱', $row['product_spec']);
|
$this->assertSame('10斤/箱', $row['product_spec']);
|
||||||
$this->assertSame('斤', $row['unit']);
|
$this->assertSame('斤', $row['unit']);
|
||||||
$this->assertEquals(20.0, $row['cost_price']);
|
$this->assertSame('20.00', (string) $row['cost_price']);
|
||||||
$this->assertEquals(2.0, $row['unit_cost'], '单价 = 20 ÷ 10');
|
$this->assertSame(5, $row['quantity'], '2+3');
|
||||||
$this->assertEquals(5.0, $row['quantity'], '2+3');
|
|
||||||
$this->assertEquals(10.0, $row['amount'], '5 × 2.00');
|
|
||||||
|
|
||||||
$cells = collect($row['cells'])->keyBy('store_id');
|
$cells = $row['cells'];
|
||||||
$this->assertSame(2, $cells[$stores[0]->id]['quantity']);
|
$this->assertSame(2, $cells[$stores[0]->id]);
|
||||||
$this->assertEquals(4.0, $cells[$stores[0]->id]['amount'], '2 × 2.00');
|
$this->assertSame(3, $cells[$stores[1]->id]);
|
||||||
$this->assertSame(3, $cells[$stores[1]->id]['quantity']);
|
|
||||||
$this->assertEquals(6.0, $cells[$stores[1]->id]['amount'], '3 × 2.00');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 门店单元格数量修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */
|
/** 门店单元格数量修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */
|
||||||
@@ -148,7 +145,73 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
$this->assertSame('20.00', (string) $purchase->actual_amount);
|
$this->assertSame('20.00', (string) $purchase->actual_amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 行级修改:成本与称重至少一项 */
|
/** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细 */
|
||||||
|
public function test_update_row_syncs_product_attributes(): void
|
||||||
|
{
|
||||||
|
[$purchase, $product] = $this->buildPurchase();
|
||||||
|
$supplier = SupplierModel::factory()->create();
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
|
||||||
|
'product_name' => '优选土豆',
|
||||||
|
'supplier_id' => $supplier->id,
|
||||||
|
'product_spec' => '5斤/袋',
|
||||||
|
'unit' => '袋',
|
||||||
|
'cost_price' => 25,
|
||||||
|
])->assertJsonPath('success', true)
|
||||||
|
->assertJsonPath('data.count', 2);
|
||||||
|
|
||||||
|
$items = StoreOrderItemModel::all();
|
||||||
|
$this->assertCount(2, $items);
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$this->assertSame('优选土豆', $item->product_name);
|
||||||
|
$this->assertSame($supplier->id, $item->supplier_id);
|
||||||
|
$this->assertSame('5斤/袋', $item->product_spec);
|
||||||
|
$this->assertSame('袋', $item->unit);
|
||||||
|
$this->assertSame('25.00', (string) $item->cost_price);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未传 sync_product:商品档案保持原值
|
||||||
|
$product = $product->fresh();
|
||||||
|
$this->assertNotSame('优选土豆', $product->name);
|
||||||
|
|
||||||
|
// 新单价 = 25 ÷ 5 = 5.00,实际金额 = 5 件 × 5.00
|
||||||
|
$this->assertSame('25.00', (string) $purchase->fresh()->actual_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sync_product=true:订货明细与商品档案同步更新;软删除商品拒绝 */
|
||||||
|
public function test_update_row_syncs_to_product_catalog(): void
|
||||||
|
{
|
||||||
|
[$purchase, $product] = $this->buildPurchase();
|
||||||
|
$supplier = SupplierModel::factory()->create();
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
|
||||||
|
'product_name' => '优选土豆',
|
||||||
|
'supplier_id' => $supplier->id,
|
||||||
|
'product_spec' => '5斤/袋',
|
||||||
|
'unit' => '袋',
|
||||||
|
'cost_price' => 25,
|
||||||
|
'sync_product' => true,
|
||||||
|
])->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$product = $product->fresh();
|
||||||
|
$this->assertSame('优选土豆', $product->name);
|
||||||
|
$this->assertSame($supplier->id, $product->supplier_id);
|
||||||
|
$this->assertSame('5斤/袋', $product->spec);
|
||||||
|
$this->assertSame('袋', $product->unit);
|
||||||
|
$this->assertSame('25.00', (string) $product->cost_price);
|
||||||
|
|
||||||
|
// 软删除商品 → 拒绝并回滚(订货明细不同步)
|
||||||
|
$product->delete();
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
|
||||||
|
'product_name' => '再次改名',
|
||||||
|
'sync_product' => true,
|
||||||
|
])->assertJsonPath('success', false);
|
||||||
|
$this->assertSame('优选土豆', StoreOrderItemModel::first()->product_name, '回滚后明细保持原值');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行级修改:属性与称重至少一项 */
|
||||||
public function test_update_row_requires_at_least_one_field(): void
|
public function test_update_row_requires_at_least_one_field(): void
|
||||||
{
|
{
|
||||||
[$purchase, $product] = $this->buildPurchase();
|
[$purchase, $product] = $this->buildPurchase();
|
||||||
|
|||||||
+12
-12
@@ -32,23 +32,23 @@ export async function exportPurchase(id: number, type: PurchaseExportType, forma
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** C4 门店单元格修改(数量/称重,同步订货明细并重算汇总) */
|
/** C4 商品行修改参数(品名/供应商/包规/单位/成本/称重,一键同步该商品全部订货明细;sync_product 追加同步商品档案) */
|
||||||
export async function updatePurchaseCell(
|
export interface PurchaseRowUpdateParams {
|
||||||
orderItemId: number,
|
product_name?: string;
|
||||||
data: { quantity: number; weight?: number },
|
supplier_id?: number;
|
||||||
) {
|
product_spec?: string;
|
||||||
return createAxios<{ quantity: number; weight: number; amount: number }>({
|
unit?: string;
|
||||||
url: `/purchase/order/cell/${orderItemId}`,
|
cost_price?: number;
|
||||||
method: 'put',
|
weight?: number;
|
||||||
data,
|
/** true = 同时同步至商品档案(product 表) */
|
||||||
});
|
sync_product?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** C4 商品行修改(成本/实际称重,同步该商品全部订货明细) */
|
/** C4 商品行修改 */
|
||||||
export async function updatePurchaseRow(
|
export async function updatePurchaseRow(
|
||||||
purchaseId: number,
|
purchaseId: number,
|
||||||
productId: number,
|
productId: number,
|
||||||
data: { cost_price?: number; weight?: number },
|
data: PurchaseRowUpdateParams,
|
||||||
) {
|
) {
|
||||||
return createAxios<{ count: number }>({
|
return createAxios<{ count: number }>({
|
||||||
url: `/purchase/order/${purchaseId}/row/${productId}`,
|
url: `/purchase/order/${purchaseId}/row/${productId}`,
|
||||||
|
|||||||
+192
-116
@@ -1,17 +1,20 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Dropdown,
|
Form,
|
||||||
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
message,
|
message,
|
||||||
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
|
Select,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { CheckOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { CheckOutlined, EditOutlined } 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 {
|
||||||
@@ -26,21 +29,31 @@ import type {
|
|||||||
} from '@/domain/iPurchaseOrder.ts';
|
} from '@/domain/iPurchaseOrder.ts';
|
||||||
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||||
import {
|
import {
|
||||||
exportPurchase,
|
|
||||||
getPurchaseDetail,
|
getPurchaseDetail,
|
||||||
|
updatePurchaseRow,
|
||||||
} from '@/api/purchase/order.ts';
|
} from '@/api/purchase/order.ts';
|
||||||
import { Update } from '@/api/common/table.ts';
|
import { Update } from '@/api/common/table.ts';
|
||||||
|
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||||
|
import type ISupplier from '@/domain/iSupplier.ts';
|
||||||
import AuthButton from '@/components/AuthButton';
|
import AuthButton from '@/components/AuthButton';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
/** 行草稿:成本/称重 + 各门店单元格数量 */
|
/** 行修改表单:品名/供应商/包规/单位/成本 */
|
||||||
interface RowDraft {
|
interface RowEditForm {
|
||||||
cost_price?: number;
|
product_name: string;
|
||||||
weight?: number;
|
supplier_id?: number;
|
||||||
cells: Record<number, number>;
|
product_spec: string;
|
||||||
|
unit: string;
|
||||||
|
cost_price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径) */
|
||||||
|
const calcUnitCost = (cost: number, spec: string): number => {
|
||||||
|
const pack = parseFloat(spec);
|
||||||
|
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单管理(C1 在门店订单页合并生成 / C2-C3 导出 / C4 明细矩阵修改)
|
* 采购单管理(C1 在门店订单页合并生成 / C2-C3 导出 / C4 明细矩阵修改)
|
||||||
* 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列
|
* 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列
|
||||||
@@ -52,16 +65,24 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [detail, setDetail] = useState<IPurchaseDetail | null>(null);
|
const [detail, setDetail] = useState<IPurchaseDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [drafts, setDrafts] = useState<Record<number, RowDraft>>({});
|
|
||||||
const [savingProductId, setSavingProductId] = useState<number | null>(null);
|
|
||||||
const [completing, setCompleting] = useState(false);
|
const [completing, setCompleting] = useState(false);
|
||||||
|
|
||||||
|
// 行修改弹窗
|
||||||
|
const [editingRow, setEditingRow] = useState<IPurchaseDetailRow | null>(null);
|
||||||
|
const [rowSaving, setRowSaving] = useState(false);
|
||||||
|
const [syncTarget, setSyncTarget] = useState<'purchase' | 'product'>('purchase');
|
||||||
|
const [editForm] = Form.useForm<RowEditForm>();
|
||||||
|
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDetail = async (id: number) => {
|
const loadDetail = async (id: number) => {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPurchaseDetail(id);
|
const res = await getPurchaseDetail(id);
|
||||||
setDetail(res.data.data ?? null);
|
setDetail(res.data.data ?? null);
|
||||||
setDrafts({});
|
|
||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
@@ -72,65 +93,39 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
await loadDetail(id);
|
await loadDetail(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDraft = (productId: number): RowDraft => drafts[productId] ?? { cells: {} };
|
const openEdit = (row: IPurchaseDetailRow) => {
|
||||||
|
setEditingRow(row);
|
||||||
const patchDraft = (productId: number, patch: Partial<RowDraft>) => {
|
editForm.setFieldsValue({
|
||||||
setDrafts((prev) => {
|
product_name: row.product_name,
|
||||||
const current = prev[productId] ?? { cells: {} };
|
supplier_id: row.supplier_id > 0 ? row.supplier_id : undefined,
|
||||||
return {
|
product_spec: row.product_spec,
|
||||||
...prev,
|
unit: row.unit,
|
||||||
[productId]: { ...current, ...patch, cells: { ...current.cells, ...patch.cells } },
|
cost_price: Number(row.cost_price),
|
||||||
};
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 草稿成本(未修改取原值) */
|
/** 提交行修改:同步该商品全部订货明细;syncTarget=product 时追加同步商品档案 */
|
||||||
const draftCost = (row: IPurchaseDetailRow): number =>
|
const handleEditSave = async (values: RowEditForm) => {
|
||||||
getDraft(row.product_id).cost_price ?? row.cost_price;
|
if (!detail || !editingRow) {
|
||||||
|
|
||||||
const isRowDirty = (row: IPurchaseDetailRow): boolean => {
|
|
||||||
// const draft = getDraft(row.product_id);
|
|
||||||
// if (draft.cost_price !== undefined && draft.cost_price !== row.cost_price) {
|
|
||||||
// return true;
|
|
||||||
// }
|
|
||||||
// if (draft.weight !== undefined && draft.weight !== row.weight) {
|
|
||||||
// return true;
|
|
||||||
// }
|
|
||||||
// return row.cells.some(
|
|
||||||
// (cell) => draft.cells[cell.order_item_id] !== undefined
|
|
||||||
// && draft.cells[cell.order_item_id] !== cell.quantity,
|
|
||||||
// );
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 保存一行:先落各门店单元格数量,再落行级成本/称重,最后刷新 */
|
|
||||||
const saveRow = async (row: IPurchaseDetailRow) => {
|
|
||||||
if (!detail || !isRowDirty(row)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const draft = getDraft(row.product_id);
|
setRowSaving(true);
|
||||||
setSavingProductId(row.product_id);
|
|
||||||
try {
|
try {
|
||||||
// for (const cell of row.cells) {
|
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
|
||||||
// const qty = draft.cells[cell.order_item_id];
|
...values,
|
||||||
// if (qty !== undefined && qty !== cell.quantity) {
|
supplier_id: values.supplier_id ?? 0,
|
||||||
// await updatePurchaseCell(cell.order_item_id, { quantity: qty });
|
sync_product: syncTarget === 'product',
|
||||||
// }
|
});
|
||||||
// }
|
message.success(
|
||||||
// const rowPatch: { cost_price?: number; weight?: number } = {};
|
syncTarget === 'product'
|
||||||
// if (draft.cost_price !== undefined && draft.cost_price !== row.cost_price) {
|
? `已同步 ${res.data.data?.count ?? 0} 条订货明细,并更新商品档案`
|
||||||
// rowPatch.cost_price = draft.cost_price;
|
: `已同步 ${res.data.data?.count ?? 0} 条订货明细`,
|
||||||
// }
|
);
|
||||||
// if (draft.weight !== undefined && draft.weight !== row.weight) {
|
setEditingRow(null);
|
||||||
// rowPatch.weight = draft.weight;
|
|
||||||
// }
|
|
||||||
// if (Object.keys(rowPatch).length > 0) {
|
|
||||||
// await updatePurchaseRow(detail.purchase.id!, row.product_id, rowPatch);
|
|
||||||
// }
|
|
||||||
message.success('已保存并同步订货明细');
|
|
||||||
await loadDetail(detail.purchase.id!);
|
await loadDetail(detail.purchase.id!);
|
||||||
await tableRef.current?.reload();
|
await tableRef.current?.reload();
|
||||||
} finally {
|
} finally {
|
||||||
setSavingProductId(null);
|
setRowSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,7 +144,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 明细矩阵列:品名/供应商/包规/单位/成本/单价 + 每门店一组(数量/金额)+ 合计 + 操作 */
|
/** 明细矩阵列:品名/供应商/单价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
|
||||||
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
|
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
|
||||||
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
||||||
{ title: '品名', dataIndex: 'product_name', width: 120, align: 'center' },
|
{ title: '品名', dataIndex: 'product_name', width: 120, align: 'center' },
|
||||||
@@ -163,27 +158,23 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '单价',
|
title: '单价',
|
||||||
key: 'unit_cost',
|
key: 'unit_cost',
|
||||||
width: 80,
|
width: 90,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => `¥${(Number(row.cost_price) / Number(row.product_spec)).toFixed(2)}`,
|
render: (_, row) => `¥${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
|
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
|
||||||
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
|
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
|
||||||
{ title: '成本', dataIndex: 'cost_price', width: 110, align: 'center', render: (v) => v || '-' }
|
{ title: '成本', dataIndex: 'cost_price', width: 90, align: 'center', render: (v) => `¥${Number(v).toFixed(2)}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const storeColumns = (detail?.stores ?? []).map((store) => ({
|
const storeColumns = (detail?.stores ?? []).map((store) => ({
|
||||||
title: store.name,
|
title: <span className={'text-[red]'}>{store.name}</span>,
|
||||||
key: `store-${store.id}`,
|
key: `store-${store.id}`,
|
||||||
|
width: 100,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (_: unknown, row: IPurchaseDetailRow) => {
|
render: (_: unknown, row: IPurchaseDetailRow) => {
|
||||||
const cell = row.cells[store.id];
|
const quantity = row.cells[store.id];
|
||||||
if (!cell) {
|
return quantity !== undefined ? <Text>{quantity}</Text> : <Text type="secondary">-</Text>;
|
||||||
return <Text type="secondary">-</Text>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Text>{ row.cells[store.id] }</Text>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -193,21 +184,21 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
key: 'total_quantity',
|
key: 'total_quantity',
|
||||||
width: 90,
|
width: 90,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => Object.values(row.cells).reduce((a, b) => a + b, 0),
|
render: (_, row) => <Text strong>{row.quantity}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 80,
|
width: 90,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
|
align: 'center',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<AuthButton auth="purchase.order.update">
|
<AuthButton auth="purchase.order.update">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
type="link"
|
type="link"
|
||||||
disabled={!isRowDirty(row)}
|
icon={<EditOutlined />}
|
||||||
loading={savingProductId === row.product_id}
|
onClick={() => openEdit(row)}
|
||||||
onClick={() => saveRow(row)}
|
|
||||||
>
|
>
|
||||||
修改
|
修改
|
||||||
</Button>
|
</Button>
|
||||||
@@ -219,6 +210,39 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
return [...fixed, ...storeColumns, ...tail];
|
return [...fixed, ...storeColumns, ...tail];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计 */
|
||||||
|
const renderSummary = () => {
|
||||||
|
const stores = detail?.stores ?? [];
|
||||||
|
const items = detail?.items ?? [];
|
||||||
|
const storeTotals = stores.map((store) =>
|
||||||
|
items.reduce(
|
||||||
|
(sum, row) => sum + (row.cells[store.id] ?? 0) * Number(row.cost_price),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
|
||||||
|
const totalAmount = storeTotals.reduce((sum, amount) => sum + amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Summary.Row>
|
||||||
|
<Table.Summary.Cell index={0} colSpan={6} align="center">
|
||||||
|
<Text strong>成本统计(按门店)</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
{storeTotals.map((amount, index) => (
|
||||||
|
<Table.Summary.Cell key={stores[index].id} index={6 + index} align="center">
|
||||||
|
<Text strong>¥{amount.toFixed(2)}</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
))}
|
||||||
|
<Table.Summary.Cell index={6 + stores.length} align="center">
|
||||||
|
<Text strong>{totalQuantity}</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={7 + stores.length} align="center">
|
||||||
|
<Text strong>¥{totalAmount.toFixed(2)}</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const columns: XinTableColumn<IPurchaseOrder>[] = [
|
const columns: XinTableColumn<IPurchaseOrder>[] = [
|
||||||
{
|
{
|
||||||
title: '采购单号',
|
title: '采购单号',
|
||||||
@@ -286,21 +310,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
|
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
|
||||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||||
详情
|
详情
|
||||||
</Button>,
|
</Button>
|
||||||
<AuthButton key="export" auth="purchase.order.export">
|
|
||||||
<Dropdown
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{ key: 'all-xlsx', label: '全品类 Excel', onClick: () => exportPurchase(record.id!, 'all', 'xlsx') },
|
|
||||||
{ key: 'all-pdf', label: '全品类 PDF', onClick: () => exportPurchase(record.id!, 'all', 'pdf') },
|
|
||||||
{ key: 'category-xlsx', label: '蔬果分类 Excel', onClick: () => exportPurchase(record.id!, 'category', 'xlsx') },
|
|
||||||
{ key: 'category-pdf', label: '蔬果分类 PDF', onClick: () => exportPurchase(record.id!, 'category', 'pdf') },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button size="small" icon={<DownloadOutlined />} />
|
|
||||||
</Dropdown>
|
|
||||||
</AuthButton>,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const tableProps: XinTableProps<IPurchaseOrder> = {
|
const tableProps: XinTableProps<IPurchaseOrder> = {
|
||||||
@@ -318,7 +328,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<Title level={3}>采购单</Title>
|
<Title level={3}>采购单</Title>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
在「门店订单」页勾选已接单订单合并生成;明细按商品行 × 门店列展示,修改数量/成本/称重后点行内「保存」同步订货明细。
|
在「门店订单」页勾选已接单订单合并生成采购单;采购单支持修改订单信息。
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<XinTable<IPurchaseOrder> {...tableProps} />
|
<XinTable<IPurchaseOrder> {...tableProps} />
|
||||||
@@ -330,25 +340,22 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
size={1200}
|
size={1200}
|
||||||
loading={detailLoading}
|
loading={detailLoading}
|
||||||
|
extra={detail?.purchase.status === 0 && (
|
||||||
|
<AuthButton auth="purchase.order.update">
|
||||||
|
<Popconfirm title="确认标记该采购单为已完成?" onConfirm={handleComplete}>
|
||||||
|
<Button size="small" icon={<CheckOutlined />} loading={completing}>
|
||||||
|
标记完成
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</AuthButton>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{detail ? (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions
|
<Title level={5} className="mt-5! mb-3!">
|
||||||
column={3}
|
采购单信息
|
||||||
size="small"
|
</Title>
|
||||||
bordered
|
<Descriptions column={3} size="small" bordered>
|
||||||
extra={
|
|
||||||
detail.purchase.status === 0 ? (
|
|
||||||
<AuthButton auth="purchase.order.update">
|
|
||||||
<Popconfirm title="确认标记该采购单为已完成?" onConfirm={handleComplete}>
|
|
||||||
<Button size="small" icon={<CheckOutlined />} loading={completing}>
|
|
||||||
标记完成
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</AuthButton>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Descriptions.Item label="采购日期">{detail.purchase.purchase_date}</Descriptions.Item>
|
<Descriptions.Item label="采购日期">{detail.purchase.purchase_date}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.color}>
|
<Tag color={PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.color}>
|
||||||
@@ -363,9 +370,9 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
|
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<div className="my-2 text-gray-500">
|
<Title level={5} className="mt-5! mb-3!">
|
||||||
单价 = 成本 ÷ 包规;门店金额 = 数量 × 单价;行级「实际称重」保存时按各店数量比例分摊到订货明细(称重>0 时金额按称重×单价计)。
|
商品明细
|
||||||
</div>
|
</Title>
|
||||||
<Table<IPurchaseDetailRow>
|
<Table<IPurchaseDetailRow>
|
||||||
rowKey="product_id"
|
rowKey="product_id"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -374,10 +381,79 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
dataSource={detail.items}
|
dataSource={detail.items}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
|
summary={renderSummary}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
{/* 行修改:品名/供应商/包规/单位/成本,同步采购单明细 / 追加同步商品档案 */}
|
||||||
|
<Modal
|
||||||
|
title={editingRow ? `修改「${editingRow.product_name}」` : '修改明细行'}
|
||||||
|
open={editingRow !== null}
|
||||||
|
onCancel={() => setEditingRow(null)}
|
||||||
|
destroyOnHidden
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={() => setEditingRow(null)}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="product"
|
||||||
|
loading={rowSaving && syncTarget === 'product'}
|
||||||
|
onClick={() => {
|
||||||
|
setSyncTarget('product');
|
||||||
|
editForm.submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存并同步
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="purchase"
|
||||||
|
type="primary"
|
||||||
|
loading={rowSaving && syncTarget === 'purchase'}
|
||||||
|
onClick={() => {
|
||||||
|
setSyncTarget('purchase');
|
||||||
|
editForm.submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="py-2 text-gray-500">
|
||||||
|
「保存」仅修改本采购单中该商品的所有订单项;「保存并同步」在此基础上同时更新商品档案(后续新订单生效)。
|
||||||
|
</div>
|
||||||
|
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
|
||||||
|
<Form.Item
|
||||||
|
label="品名"
|
||||||
|
name="product_name"
|
||||||
|
rules={[{ required: true, message: '请输入品名' }, { max: 100 }]}
|
||||||
|
>
|
||||||
|
<Input maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="供应商" name="supplier_id">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
placeholder="选择供应商"
|
||||||
|
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="包规" name="product_spec" rules={[{ max: 100 }]}>
|
||||||
|
<Input maxLength={100} placeholder="如:10斤/箱" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="单位" name="unit" rules={[{ max: 20 }]}>
|
||||||
|
<Input maxLength={20} placeholder="如:斤" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="成本"
|
||||||
|
name="cost_price"
|
||||||
|
rules={[{ required: true, message: '请输入成本' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} precision={2} prefix="¥" className="w-full" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user