Compare commits
5 Commits
0211e5e98d
...
61a70319a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 61a70319a0 | |||
| 099e92b1ff | |||
| 79e3ed4a8a | |||
| 91ea91784d | |||
| 6c794d0732 |
File diff suppressed because one or more lines are too long
@@ -16,8 +16,7 @@ use Maatwebsite\Excel\Concerns\WithStyles;
|
|||||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单导出(C2 全品类按分类 sort 排序 / C3 仅蔬果分类)
|
* 采购单导出
|
||||||
* 数据源为订货明细按商品聚合(无独立采购明细表),每门店一列显示数量
|
|
||||||
*/
|
*/
|
||||||
class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles
|
class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles
|
||||||
{
|
{
|
||||||
@@ -135,7 +134,7 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
|||||||
|
|
||||||
return array_merge(
|
return array_merge(
|
||||||
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
||||||
array_map(static fn (string $name): string => $name . '(数量)', array_values($this->storeNames)),
|
array_values($this->storeNames),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,23 +83,4 @@ class StatementController extends BaseMiniController
|
|||||||
|
|
||||||
return $this->success($statement->toArray());
|
return $this->success($statement->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出对账单:?format=xlsx|pdf */
|
|
||||||
#[GetRoute('/statement/{id}/export', authorize: true, where: ['id' => '[0-9]+'])]
|
|
||||||
public function export(int $id, Request $request): Response
|
|
||||||
{
|
|
||||||
$user = $this->currentUser($request);
|
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$statement = StatementModel::where('store_id', $store->id)->find($id);
|
|
||||||
if ($statement === null) {
|
|
||||||
throw new RepositoryException('对账单不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(ExportService::class)->download(
|
|
||||||
'statement',
|
|
||||||
$statement,
|
|
||||||
(string) $request->query('format', ExportService::FORMAT_XLSX),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class StoreOrderController extends BaseController
|
|||||||
$query = StoreOrderModel::query()->with([
|
$query = StoreOrderModel::query()->with([
|
||||||
'store:id,name,address,contact,phone',
|
'store:id,name,address,contact,phone',
|
||||||
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
|
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
|
||||||
|
'purchase:id,purchase_no,purchase_date,status'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
|
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
|
||||||
@@ -206,60 +207,6 @@ class StoreOrderController extends BaseController
|
|||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 修改周转框/周转托盘数量
|
|
||||||
*/
|
|
||||||
#[PutRoute(route: '/{id}/container', authorize: 'update', where: ['id' => '[0-9]+'])]
|
|
||||||
public function container(int $id, Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'box_num' => 'required|integer|min:0',
|
|
||||||
'tray_num' => 'required|integer|min:0',
|
|
||||||
], [
|
|
||||||
'box_num.required' => '周转框数量不能为空',
|
|
||||||
'box_num.integer' => '周转框数量必须为整数',
|
|
||||||
'box_num.min' => '周转框数量不能小于 0',
|
|
||||||
'tray_num.required' => '周转托盘数量不能为空',
|
|
||||||
'tray_num.integer' => '周转托盘数量必须为整数',
|
|
||||||
'tray_num.min' => '周转托盘数量不能小于 0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$order = StoreOrderModel::find($id);
|
|
||||||
if (empty($order)) {
|
|
||||||
throw new RepositoryException('订单不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
$editable = [
|
|
||||||
StoreOrderModel::STATUS_SUMMARIZED,
|
|
||||||
StoreOrderModel::STATUS_DELIVERING,
|
|
||||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
|
||||||
];
|
|
||||||
if (! in_array($order->status, $editable, true)) {
|
|
||||||
throw new RepositoryException(
|
|
||||||
'订单当前状态为「' . (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改周转框/托盘数量'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$boxPrice = (float) site_config('services.box_amount', 0);
|
|
||||||
$trayPrice = (float) site_config('services.tray_amount', 0);
|
|
||||||
$addedAmount = round($data['box_num'] * $boxPrice + $data['tray_num'] * $trayPrice, 2);
|
|
||||||
|
|
||||||
// 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立
|
|
||||||
$productAmount = (float) $order->product_amount;
|
|
||||||
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
|
|
||||||
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
$order->box_num = (int) $data['box_num'];
|
|
||||||
$order->tray_num = (int) $data['tray_num'];
|
|
||||||
$order->product_amount = $productAmount;
|
|
||||||
$order->added_amount = $addedAmount;
|
|
||||||
$order->total_amount = round($productAmount + $addedAmount, 2);
|
|
||||||
$order->save();
|
|
||||||
|
|
||||||
return $this->success();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态流转合法路径
|
* 状态流转合法路径
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Purchase;
|
namespace App\Http\Controllers\Purchase;
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Exports\PurchaseOrderExport;
|
||||||
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
|
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
|
||||||
|
use App\Http\Requests\Purchase\PurchaseContainerUpdateRequest;
|
||||||
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
|
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
@@ -12,19 +14,21 @@ use App\Models\StoreOrderItemModel;
|
|||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Services\ItemImageResolver;
|
use App\Services\ItemImageResolver;
|
||||||
use App\Services\PurchaseGenerateService;
|
use App\Services\PurchaseGenerateService;
|
||||||
|
use App\Services\StoreOrderContainerService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
use Modules\Common\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改)
|
* 采购单管理
|
||||||
* 采购单无独立明细表,明细直接溯源门店订货明细(store_order_item.purchase_id)
|
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/purchase/order', 'purchase.order')]
|
#[RequestAttribute('/purchase/order', 'purchase.order')]
|
||||||
class PurchaseOrderController extends BaseController
|
class PurchaseOrderController extends BaseController
|
||||||
@@ -131,6 +135,45 @@ class PurchaseOrderController extends BaseController
|
|||||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||||
|
|
||||||
|
// 周转框/托盘合并记录:按门店聚合全部订单(附底层订单明细,供采购单完成前修改)
|
||||||
|
$storeOrders = StoreOrderModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['id', 'order_no', 'store_id', 'box_num', 'tray_num']);
|
||||||
|
|
||||||
|
$boxPrice = (float) site_config('services.box_amount', 0);
|
||||||
|
$trayPrice = (float) site_config('services.tray_amount', 0);
|
||||||
|
$storeSort = $stores->pluck('id')->flip();
|
||||||
|
$containers = [];
|
||||||
|
foreach ($storeOrders->groupBy('store_id') as $storeId => $orders) {
|
||||||
|
$boxNum = (int) $orders->sum('box_num');
|
||||||
|
$trayNum = (int) $orders->sum('tray_num');
|
||||||
|
$containers[] = [
|
||||||
|
'store_id' => (int) $storeId,
|
||||||
|
'store_name' => $stores->firstWhere('id', (int) $storeId)['name'] ?? '门店#' . $storeId,
|
||||||
|
'box_num' => $boxNum,
|
||||||
|
'tray_num' => $trayNum,
|
||||||
|
'box_price' => number_format($boxPrice, 2),
|
||||||
|
'tray_price' => number_format($trayPrice, 2),
|
||||||
|
'added_amount' => number_format($boxNum * $boxPrice + $trayNum * $trayPrice, 2),
|
||||||
|
'order_count' => $orders->count(),
|
||||||
|
'orders' => $orders->map(static fn (StoreOrderModel $order) => [
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'order_no' => $order->order_no,
|
||||||
|
'box_num' => (int) $order->box_num,
|
||||||
|
'tray_num' => (int) $order->tray_num,
|
||||||
|
])->values()->toArray(),
|
||||||
|
'store_sort' => (int) ($storeSort[$storeId] ?? 9999),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($containers, static fn (array $a, array $b): int =>
|
||||||
|
[$a['store_sort'], $a['store_id']] <=> [$b['store_sort'], $b['store_id']]);
|
||||||
|
$containers = array_map(static function (array $row): array {
|
||||||
|
unset($row['store_sort']);
|
||||||
|
return $row;
|
||||||
|
}, $containers);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'purchase' => $purchase->toArray(),
|
'purchase' => $purchase->toArray(),
|
||||||
'stores' => $stores->toArray(),
|
'stores' => $stores->toArray(),
|
||||||
@@ -138,29 +181,65 @@ class PurchaseOrderController extends BaseController
|
|||||||
unset($row['category_sort'], $row['product_sort']);
|
unset($row['category_sort'], $row['product_sort']);
|
||||||
return $row;
|
return $row;
|
||||||
}, $rows),
|
}, $rows),
|
||||||
|
'containers' => $containers,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** C4 修改采购单头信息(采购日期、状态、备注) */
|
/** 修改采购单信息(采购日期、实际金额、备注) */
|
||||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
public function update(int $id, Request $request): JsonResponse
|
public function update(int $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'purchase_date' => 'nullable|date_format:Y-m-d',
|
'actual_amount' => 'required|numeric|min:0',
|
||||||
'status' => 'nullable|integer|in:' . PurchaseOrderModel::STATUS_PENDING . ',' . PurchaseOrderModel::STATUS_COMPLETED,
|
|
||||||
'remark' => 'nullable|string|max:255',
|
'remark' => 'nullable|string|max:255',
|
||||||
], [
|
], [
|
||||||
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
|
'actual_amount.required' => '实际金额不能为空',
|
||||||
'status.in' => '采购单状态不正确',
|
'actual_amount.numeric' => '实际金额格式错误',
|
||||||
|
'actual_amount.min' => '实际金额不能小于0',
|
||||||
|
'remark.max' => '备注超过最大长度',
|
||||||
]);
|
]);
|
||||||
$purchase = PurchaseOrderModel::find($id);
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
if (empty($purchase)) {
|
if (empty($purchase)) {
|
||||||
throw new RepositoryException('采购单不存在');
|
throw new RepositoryException('采购单不存在');
|
||||||
}
|
}
|
||||||
|
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||||
|
throw new RepositoryException('采购单已完成,不允许修改信息');
|
||||||
|
}
|
||||||
$purchase->update(array_filter($data, static fn ($v) => $v !== null));
|
$purchase->update(array_filter($data, static fn ($v) => $v !== null));
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成采购单
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
#[PutRoute(route: '/{id}/finish', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
|
public function finish(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||||
|
throw new RepositoryException('采购单已完成,不允许修改信息');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($purchase) {
|
||||||
|
// 修改采购单状态
|
||||||
|
$purchase->status = PurchaseOrderModel::STATUS_COMPLETED;
|
||||||
|
$purchase->save();
|
||||||
|
// 修改所有订单状态为配送中
|
||||||
|
StoreOrderModel::where('purchase_id', $purchase->id)->update([
|
||||||
|
'status' => StoreOrderModel::STATUS_DISTRIBUTION
|
||||||
|
]);
|
||||||
|
// 生成并发送账单
|
||||||
|
|
||||||
|
return $this->success();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成采购单
|
* 生成采购单
|
||||||
*/
|
*/
|
||||||
@@ -190,6 +269,29 @@ class PurchaseOrderController extends BaseController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出采购单 Excel 表格
|
||||||
|
*
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
|
||||||
|
public function export(int $id, Request $request): Response
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
$type = (string) $request->query('type', 'all');
|
||||||
|
if (! in_array($type, ['all', 'category'], true)) {
|
||||||
|
throw new RepositoryException('导出类型参数不正确(all 全品类 / category 蔬果分类)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Excel::download(
|
||||||
|
new PurchaseOrderExport($purchase, $type),
|
||||||
|
$purchase->purchase_no . '_采购单.xlsx',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单元格下钻:采购单中某门店某商品的全部订货明细
|
* 单元格下钻:采购单中某门店某商品的全部订货明细
|
||||||
*/
|
*/
|
||||||
@@ -243,8 +345,6 @@ class PurchaseOrderController extends BaseController
|
|||||||
'amount' => (string) $item->amount,
|
'amount' => (string) $item->amount,
|
||||||
'remark' => (string) $item->remark,
|
'remark' => (string) $item->remark,
|
||||||
'image_ids' => $item->image_ids,
|
'image_ids' => $item->image_ids,
|
||||||
'editable' => $purchase->status === PurchaseOrderModel::STATUS_PENDING
|
|
||||||
&& in_array((int) $item->order_status, StoreOrderItemModel::ITEM_EDITABLE_STATUS, true),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
app(ItemImageResolver::class)->resolve($rows);
|
app(ItemImageResolver::class)->resolve($rows);
|
||||||
@@ -259,6 +359,152 @@ class PurchaseOrderController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店购买详情:采购单内指定门店的采购汇总(按商品聚合)
|
||||||
|
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=预计金额
|
||||||
|
*/
|
||||||
|
#[GetRoute(route: '/{id}/store', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||||
|
public function storeSummary(int $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$storeId = (int) $request->query('store_id', 0);
|
||||||
|
if ($storeId <= 0) {
|
||||||
|
throw new RepositoryException('缺少门店参数');
|
||||||
|
}
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = StoreOrderItemModel::query()
|
||||||
|
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||||
|
->where('store_order_item.purchase_id', $purchase->id)
|
||||||
|
->where('store_order_item.store_id', $storeId)
|
||||||
|
->whereNull('store_order.deleted_at')
|
||||||
|
->select('store_order_item.*')
|
||||||
|
->orderBy('store_order_item.id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// 排序键:分类 sort → 商品 sort(与明细矩阵同序)
|
||||||
|
$products = ProductModel::withTrashed()
|
||||||
|
->with('category:id,sort')
|
||||||
|
->whereIn('id', $items->pluck('product_id')->unique())
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($items->groupBy('product_id') as $productId => $group) {
|
||||||
|
$product = $products->get((int) $productId);
|
||||||
|
$first = $group->first();
|
||||||
|
// 采购总数量(包数)
|
||||||
|
$quantity = 0;
|
||||||
|
// 预计金额 = Σ 明细 amount
|
||||||
|
$amount = '0';
|
||||||
|
// 总重量
|
||||||
|
$weight = '0';
|
||||||
|
foreach ($group as $item) {
|
||||||
|
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||||
|
$quantity += (int) $item->quantity;
|
||||||
|
$amount = bcadd($amount, (string) $item->amount, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'product_id' => (int) $productId,
|
||||||
|
'product_name' => $first->product_name,
|
||||||
|
'product_spec' => $first->product_spec, // 包规
|
||||||
|
'unit' => $first->unit, // 单位
|
||||||
|
'price' => $quantity > 0 // 加权平均单价
|
||||||
|
? bcdiv($amount, (string) $quantity, 2)
|
||||||
|
: (string) $first->price,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'amount' => $amount,
|
||||||
|
'weight' => $weight,
|
||||||
|
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||||
|
'product_sort' => (int) ($product->sort ?? 9999),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($rows, static fn (array $a, array $b): int =>
|
||||||
|
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||||
|
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||||
|
|
||||||
|
$store = StoreModel::withTrashed()->find($storeId);
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
|
||||||
|
'items' => array_map(static function (array $row): array {
|
||||||
|
unset($row['category_sort'], $row['product_sort']);
|
||||||
|
return $row;
|
||||||
|
}, $rows),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(仅采购单进行中可改)
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
#[PutRoute(route: '/{id}/container/{storeId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])]
|
||||||
|
public function updateContainer(int $id, int $storeId, PurchaseContainerUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||||
|
throw new RepositoryException('采购单已完成,不允许修改周转框/托盘');
|
||||||
|
}
|
||||||
|
|
||||||
|
$submitted = $request->validated()['orders'];
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($purchase, $storeId, $submitted) {
|
||||||
|
$orders = StoreOrderModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->where('store_id', $storeId)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($orders->isEmpty()) {
|
||||||
|
throw new RepositoryException('该采购单下无此门店的订单');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并记录修改必须覆盖该门店全部订单,避免只改部分造成汇总偏差
|
||||||
|
$actualIds = $orders->pluck('id')->map(static fn ($orderId) => (int) $orderId)->all();
|
||||||
|
$submittedIds = array_map(static fn (array $row) => (int) $row['order_id'], $submitted);
|
||||||
|
$missing = array_diff($actualIds, $submittedIds);
|
||||||
|
if ($missing !== []) {
|
||||||
|
throw new RepositoryException('提交不完整,缺少订单:' . implode('、', $missing));
|
||||||
|
}
|
||||||
|
if (array_diff($submittedIds, $actualIds) !== []) {
|
||||||
|
throw new RepositoryException('包含不属于该采购单该门店的订单');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
if (! in_array($order->status, [
|
||||||
|
StoreOrderModel::STATUS_SUMMARIZED,
|
||||||
|
StoreOrderModel::STATUS_DELIVERING,
|
||||||
|
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||||
|
], true)) {
|
||||||
|
throw new RepositoryException(
|
||||||
|
'订单 ' . $order->order_no . ' 当前状态为「'
|
||||||
|
. (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status)
|
||||||
|
. '」,不允许修改周转框/托盘数量'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ordersById = $orders->keyBy('id');
|
||||||
|
$containerService = app(StoreOrderContainerService::class);
|
||||||
|
foreach ($submitted as $row) {
|
||||||
|
$containerService->update(
|
||||||
|
$ordersById->get($row['order_id']),
|
||||||
|
(int) $row['box_num'],
|
||||||
|
(int) $row['tray_num'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品行修改:品名/供应商/包规/单位/成本
|
* 商品行修改:品名/供应商/包规/单位/成本
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ namespace App\Http\Controllers\Recon;
|
|||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Models\SettlementModel;
|
use App\Models\SettlementModel;
|
||||||
use App\Services\ExportService;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
use Modules\Common\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
|
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
|
||||||
@@ -52,26 +50,4 @@ class SettlementController extends BaseController
|
|||||||
}
|
}
|
||||||
return $this->success($settlement->toArray());
|
return $this->success($settlement->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* D10 导出下载:?format=xlsx|pdf,成功后回写 file_path 存档标记
|
|
||||||
*/
|
|
||||||
#[GetRoute(route: '/{id}/download', authorize: 'download', where: ['id' => '[0-9]+'])]
|
|
||||||
public function download(int $id, Request $request): Response
|
|
||||||
{
|
|
||||||
$settlement = SettlementModel::find($id);
|
|
||||||
if (empty($settlement)) {
|
|
||||||
throw new RepositoryException('结算表不存在');
|
|
||||||
}
|
|
||||||
$format = (string) $request->query('format', ExportService::FORMAT_XLSX);
|
|
||||||
|
|
||||||
$response = app(ExportService::class)->download('settlement', $settlement, $format);
|
|
||||||
|
|
||||||
// 同步流式下载不落盘,file_path 仅作存档标记(后续切队列导出时替换为真实文件路径)
|
|
||||||
$extension = $format === ExportService::FORMAT_PDF ? 'pdf' : 'xlsx';
|
|
||||||
$settlement->file_path = 'exports/settlement/' . $settlement->settlement_no . '.' . $extension;
|
|
||||||
$settlement->save();
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Purchase;
|
||||||
|
|
||||||
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单周转框/托盘合并记录修改 验证(按门店覆盖全部订单,逐笔重算附加金额)
|
||||||
|
*/
|
||||||
|
class PurchaseContainerUpdateRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected $stopOnFirstFailure = true;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'orders' => 'required|array|min:1',
|
||||||
|
'orders.*.order_id' => 'required|integer|distinct',
|
||||||
|
'orders.*.box_num' => 'required|integer|min:0',
|
||||||
|
'orders.*.tray_num' => 'required|integer|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'orders.required' => '请提交门店订单周转框/托盘数量',
|
||||||
|
'orders.array' => '订单数据格式错误',
|
||||||
|
'orders.min' => '请提交门店订单周转框/托盘数量',
|
||||||
|
'orders.*.order_id.required' => '订单ID不能为空',
|
||||||
|
'orders.*.order_id.integer' => '订单ID格式错误',
|
||||||
|
'orders.*.order_id.distinct' => '存在重复订单',
|
||||||
|
'orders.*.box_num.required' => '周转框数量不能为空',
|
||||||
|
'orders.*.box_num.integer' => '周转框数量必须为整数',
|
||||||
|
'orders.*.box_num.min' => '周转框数量不能小于 0',
|
||||||
|
'orders.*.tray_num.required' => '周转托盘数量不能为空',
|
||||||
|
'orders.*.tray_num.integer' => '周转托盘数量必须为整数',
|
||||||
|
'orders.*.tray_num.min' => '周转托盘数量不能小于 0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ class PurchaseOrderModel extends Model
|
|||||||
'estimate_amount' => 'decimal:2',
|
'estimate_amount' => 'decimal:2',
|
||||||
'actual_amount' => 'decimal:2',
|
'actual_amount' => 'decimal:2',
|
||||||
'operator_id' => 'integer',
|
'operator_id' => 'integer',
|
||||||
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -88,4 +88,12 @@ class StoreOrderModel extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(StoreOrderItemModel::class, 'order_id', 'id');
|
return $this->hasMany(StoreOrderItemModel::class, 'order_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单
|
||||||
|
*/
|
||||||
|
public function purchase(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
|
||||||
use App\Exports\PurchaseOrderExport;
|
|
||||||
use App\Exports\SettlementExport;
|
|
||||||
use App\Exports\StatementExport;
|
|
||||||
use App\Models\PurchaseOrderModel;
|
|
||||||
use App\Models\SettlementModel;
|
|
||||||
use App\Models\StatementModel;
|
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出统一入口:按业务类型 + format 分发到 app/Exports 导出类 / PDF 模板
|
|
||||||
*
|
|
||||||
* - Excel 分支:Excel::download(new XxxExport(...))
|
|
||||||
* - PDF 分支:Pdf::loadView('exports.xxx', ...)->setPaper('a4')->download(...),模板统一 font-family: SimHei
|
|
||||||
* - 文件名规范:{单号}_{业务名}.{ext},中文文件名由响应自动做 RFC 5987 编码
|
|
||||||
*/
|
|
||||||
class ExportService
|
|
||||||
{
|
|
||||||
/** 导出格式:Excel */
|
|
||||||
public const FORMAT_XLSX = 'xlsx';
|
|
||||||
/** 导出格式:PDF */
|
|
||||||
public const FORMAT_PDF = 'pdf';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出下载
|
|
||||||
*
|
|
||||||
* @param string $business 业务类型:purchase 采购单 / statement 门店对账单 / settlement 结算表
|
|
||||||
* @param mixed $subject 业务主体(如 PurchaseOrderModel / StatementModel / SettlementModel 实例)
|
|
||||||
* @param string $format 导出格式 xlsx|pdf,默认 xlsx,非法值报错
|
|
||||||
* @param string|null $type 业务子类型(purchase 专用:all 全品类 / category 仅蔬果分类)
|
|
||||||
* @return Response 文件流响应(blob)
|
|
||||||
*/
|
|
||||||
public function download(
|
|
||||||
string $business,
|
|
||||||
mixed $subject,
|
|
||||||
string $format = self::FORMAT_XLSX,
|
|
||||||
?string $type = null,
|
|
||||||
): Response {
|
|
||||||
if (! in_array($format, [self::FORMAT_XLSX, self::FORMAT_PDF], true)) {
|
|
||||||
throw new RepositoryException('导出格式参数不正确(仅支持 xlsx / pdf)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($business) {
|
|
||||||
'purchase' => $this->exportPurchase($subject, $format, $type),
|
|
||||||
'statement' => $this->exportStatement($subject, $format),
|
|
||||||
'settlement' => $this->exportSettlement($subject, $format),
|
|
||||||
default => throw new RepositoryException('不支持的导出业务类型:' . $business),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* C2/C3 采购单导出
|
|
||||||
*/
|
|
||||||
private function exportPurchase(PurchaseOrderModel $purchase, string $format, ?string $type): Response
|
|
||||||
{
|
|
||||||
$type = in_array($type, ['all', 'category'], true) ? $type : 'all';
|
|
||||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
|
||||||
$filename = $purchase->purchase_no . '_采购单.' . $extension;
|
|
||||||
|
|
||||||
$export = new PurchaseOrderExport($purchase, $type);
|
|
||||||
if ($format === self::FORMAT_PDF) {
|
|
||||||
return Pdf::loadView('exports.purchase', $export->viewData())
|
|
||||||
->setPaper('a4')
|
|
||||||
->download($filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Excel::download($export, $filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 门店对账单导出
|
|
||||||
*/
|
|
||||||
private function exportStatement(StatementModel $statement, string $format): Response
|
|
||||||
{
|
|
||||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
|
||||||
$filename = $statement->statement_no . '_对账单.' . $extension;
|
|
||||||
|
|
||||||
$export = new StatementExport($statement);
|
|
||||||
if ($format === self::FORMAT_PDF) {
|
|
||||||
return Pdf::loadView('exports.statement', $export->viewData())
|
|
||||||
->setPaper('a4')
|
|
||||||
->download($filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Excel::download($export, $filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* D10 结算表导出
|
|
||||||
*/
|
|
||||||
private function exportSettlement(SettlementModel $settlement, string $format): Response
|
|
||||||
{
|
|
||||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
|
||||||
$filename = $settlement->settlement_no . '_结算表.' . $extension;
|
|
||||||
|
|
||||||
$export = new SettlementExport($settlement);
|
|
||||||
if ($format === self::FORMAT_PDF) {
|
|
||||||
return Pdf::loadView('exports.settlement', $export->viewData())
|
|
||||||
->setPaper('a4')
|
|
||||||
->download($filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Excel::download($export, $filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,9 +58,9 @@ readonly class PurchaseGenerateService
|
|||||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2),
|
static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2),
|
||||||
'0'
|
'0'
|
||||||
);
|
);
|
||||||
// 预估金额 = Σ订货金额(明细金额 price×quantity),与采购单修改重算口径一致
|
// 预估成本
|
||||||
$estimateAmount = $items->reduce(
|
$estimateAmount = $items->reduce(
|
||||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2),
|
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
|
||||||
'0'
|
'0'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\StoreOrderModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周转框/周转托盘数量修改(订单附加金额重算)
|
||||||
|
*/
|
||||||
|
readonly class StoreOrderContainerService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 更新订单周转框/托盘数量并重算附加金额与订单总金额。
|
||||||
|
* 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立。
|
||||||
|
*/
|
||||||
|
public function update(StoreOrderModel $order, int $boxNum, int $trayNum): void
|
||||||
|
{
|
||||||
|
$boxPrice = (float) site_config('services.box_amount', 0);
|
||||||
|
$trayPrice = (float) site_config('services.tray_amount', 0);
|
||||||
|
$addedAmount = round($boxNum * $boxPrice + $trayNum * $trayPrice, 2);
|
||||||
|
|
||||||
|
$productAmount = (float) $order->product_amount;
|
||||||
|
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
|
||||||
|
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->box_num = $boxNum;
|
||||||
|
$order->tray_num = $trayNum;
|
||||||
|
$order->product_amount = $productAmount;
|
||||||
|
$order->added_amount = $addedAmount;
|
||||||
|
$order->total_amount = round($productAmount + $addedAmount, 2);
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ use App\Models\UserModel;
|
|||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单导出:xlsx Content-Type / 蔬果分类过滤 / PDF / format 非法拒绝 / 权限拦截 /
|
* 采购单导出(仅 Excel 表格):xlsx Content-Type / 蔬果分类过滤 / type 非法拒绝 /
|
||||||
* 中文文件名 RFC 5987 编码
|
* 权限拦截 / 中文文件名 RFC 5987 编码
|
||||||
*/
|
*/
|
||||||
class ExportTest extends ProcurementTestCase
|
class ExportTest extends ProcurementTestCase
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ class ExportTest extends ProcurementTestCase
|
|||||||
$purchase = $this->buildPurchaseWithItems();
|
$purchase = $this->buildPurchaseWithItems();
|
||||||
$this->actingAsSysUser();
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
$response = $this->get("/purchase/order/{$purchase->id}/export?type=all&format=xlsx");
|
$response = $this->get("/purchase/order/{$purchase->id}/export");
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
|
|
||||||
$this->assertStringContainsString(
|
$this->assertStringContainsString(
|
||||||
@@ -78,7 +78,7 @@ class ExportTest extends ProcurementTestCase
|
|||||||
$this->actingAsSysUser();
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
Excel::fake();
|
Excel::fake();
|
||||||
$this->get("/purchase/order/{$purchase->id}/export?type=category&format=xlsx")->assertOk();
|
$this->get("/purchase/order/{$purchase->id}/export?type=category")->assertOk();
|
||||||
|
|
||||||
Excel::assertDownloaded(
|
Excel::assertDownloaded(
|
||||||
$purchase->purchase_no . '_采购单.xlsx',
|
$purchase->purchase_no . '_采购单.xlsx',
|
||||||
@@ -90,25 +90,22 @@ class ExportTest extends ProcurementTestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PDF 导出:application/pdf */
|
/** type 参数非法 → 拒绝 */
|
||||||
public function test_export_pdf(): void
|
public function test_export_invalid_type_rejected(): void
|
||||||
{
|
{
|
||||||
$purchase = $this->buildPurchaseWithItems();
|
$purchase = $this->buildPurchaseWithItems();
|
||||||
$this->actingAsSysUser();
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
$response = $this->get("/purchase/order/{$purchase->id}/export?format=pdf");
|
$this->get("/purchase/order/{$purchase->id}/export?type=doc")
|
||||||
$response->assertOk();
|
->assertJsonPath('success', false);
|
||||||
$this->assertStringContainsString('application/pdf', (string) $response->headers->get('Content-Type'));
|
|
||||||
$this->assertStringStartsWith('%PDF', (string) $response->getContent());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** format 参数非法 → 拒绝 */
|
/** 采购单不存在 → 拒绝 */
|
||||||
public function test_export_invalid_format_rejected(): void
|
public function test_export_missing_purchase_rejected(): void
|
||||||
{
|
{
|
||||||
$purchase = $this->buildPurchaseWithItems();
|
|
||||||
$this->actingAsSysUser();
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
$this->get("/purchase/order/{$purchase->id}/export?format=doc")
|
$this->get('/purchase/order/99999/export')
|
||||||
->assertJsonPath('success', false);
|
->assertJsonPath('success', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +116,7 @@ class ExportTest extends ProcurementTestCase
|
|||||||
// 仅持有查询权限的用户
|
// 仅持有查询权限的用户
|
||||||
$this->actingAsSysUser(['purchase.order.query']);
|
$this->actingAsSysUser(['purchase.order.query']);
|
||||||
|
|
||||||
$response = $this->get("/purchase/order/{$purchase->id}/export?format=xlsx");
|
$response = $this->get("/purchase/order/{$purchase->id}/export");
|
||||||
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
|
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ use App\Models\StoreOrderItemModel;
|
|||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Models\SupplierModel;
|
use App\Models\SupplierModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
||||||
|
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本,
|
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本,
|
||||||
@@ -52,6 +55,23 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
|
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 播种站点配置:周转框单价 2.00、周转托盘单价 10.00 */
|
||||||
|
private function seedContainerConfig(): void
|
||||||
|
{
|
||||||
|
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
|
||||||
|
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
|
||||||
|
SysSiteConfigItemsModel::create([
|
||||||
|
'group_id' => $group->id,
|
||||||
|
'key' => $key,
|
||||||
|
'title' => $title,
|
||||||
|
'type' => 'InputNumber',
|
||||||
|
'values' => $value,
|
||||||
|
'sort' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Cache::forget('site_config');
|
||||||
|
}
|
||||||
|
|
||||||
/** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
|
/** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
|
||||||
public function test_detail_returns_store_matrix(): void
|
public function test_detail_returns_store_matrix(): void
|
||||||
{
|
{
|
||||||
@@ -335,6 +355,87 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
$this->assertSame('40.00', (string) $purchase->actual_amount, '2 包 × 每包成本 20.00');
|
$this->assertSame('40.00', (string) $purchase->actual_amount, '2 包 × 每包成本 20.00');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 门店购买详情:按商品聚合该门店采购汇总(数量/包规/单位/单价/预计金额),仅含该门店明细 */
|
||||||
|
public function test_store_summary_returns_aggregated_items(): void
|
||||||
|
{
|
||||||
|
[$purchase, $product, $stores] = $this->buildPurchase();
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
|
$response = $this->getJson("/purchase/order/{$purchase->id}/store?store_id={$stores[0]->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$data = $response->json('data');
|
||||||
|
$this->assertSame($stores[0]->name, $data['store']['name']);
|
||||||
|
$this->assertCount(1, $data['items']);
|
||||||
|
|
||||||
|
$row = $data['items'][0];
|
||||||
|
$this->assertSame($product->id, $row['product_id']);
|
||||||
|
$this->assertSame('10斤/箱', $row['product_spec']);
|
||||||
|
$this->assertSame('斤', $row['unit']);
|
||||||
|
$this->assertSame('10.00', (string) $row['price']);
|
||||||
|
$this->assertSame(2, $row['quantity']);
|
||||||
|
$this->assertSame('20.00', (string) $row['amount']);
|
||||||
|
|
||||||
|
// 另一门店只见自身明细
|
||||||
|
$this->getJson("/purchase/order/{$purchase->id}/store?store_id={$stores[1]->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.quantity', 3)
|
||||||
|
->assertJsonPath('data.items.0.amount', '30.00');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店购买详情:同一商品多笔订单不同单价 → 数量合计、金额求和、单价为加权平均 */
|
||||||
|
public function test_store_summary_aggregates_multiple_orders_with_weighted_price(): void
|
||||||
|
{
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
|
$product = ProductModel::factory()->create([
|
||||||
|
'status' => ProductModel::STATUS_ON,
|
||||||
|
'cost_price' => 20,
|
||||||
|
'spec' => '10斤/箱',
|
||||||
|
'unit' => '斤',
|
||||||
|
]);
|
||||||
|
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||||
|
|
||||||
|
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||||
|
foreach ([2, 3] as $qty) {
|
||||||
|
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
}
|
||||||
|
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||||
|
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$purchase = PurchaseOrderModel::first();
|
||||||
|
// 第二笔明细单价改为 20.00,构造同商品双单价:2×10 + 3×20
|
||||||
|
$secondItem = StoreOrderItemModel::orderBy('id')->skip(1)->first();
|
||||||
|
$this->putJson("/purchase/order/cell/{$secondItem->id}", ['quantity' => 3, 'price' => 20])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$this->getJson("/purchase/order/{$purchase->id}/store?store_id={$store->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.quantity', 5)
|
||||||
|
->assertJsonPath('data.items.0.price', '16.00', '(20+60) ÷ 5 加权平均')
|
||||||
|
->assertJsonPath('data.items.0.amount', '80.00');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店购买详情参数校验:缺少门店参数 / 采购单不存在均拒绝 */
|
||||||
|
public function test_store_summary_rejects_missing_or_invalid_params(): void
|
||||||
|
{
|
||||||
|
[$purchase] = $this->buildPurchase();
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
|
$this->getJson("/purchase/order/{$purchase->id}/store")
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '缺少门店参数');
|
||||||
|
|
||||||
|
$this->getJson('/purchase/order/99999/store?store_id=1')
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '采购单不存在');
|
||||||
|
}
|
||||||
|
|
||||||
/** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */
|
/** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */
|
||||||
public function test_edits_rejected_when_purchase_completed(): void
|
public function test_edits_rejected_when_purchase_completed(): void
|
||||||
{
|
{
|
||||||
@@ -359,4 +460,148 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.items.0.editable', false);
|
->assertJsonPath('data.items.0.editable', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 详情返回周转框/托盘合并记录:按门店聚合全部订单(含底层订单明细与单价) */
|
||||||
|
public function test_detail_returns_container_summary(): void
|
||||||
|
{
|
||||||
|
$this->seedContainerConfig();
|
||||||
|
[$purchase, , $stores] = $this->buildPurchase();
|
||||||
|
|
||||||
|
StoreOrderModel::where('store_id', $stores[0]->id)->update(['box_num' => 2, 'tray_num' => 1]);
|
||||||
|
StoreOrderModel::where('store_id', $stores[1]->id)->update(['box_num' => 3, 'tray_num' => 0]);
|
||||||
|
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$containers = $this->getJson("/purchase/order/{$purchase->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true)
|
||||||
|
->json('data.containers');
|
||||||
|
$this->assertCount(2, $containers);
|
||||||
|
$this->assertSame($stores[0]->id, $containers[0]['store_id'], '与门店列同序');
|
||||||
|
|
||||||
|
$row = $containers[0];
|
||||||
|
$this->assertSame($stores[0]->name, $row['store_name']);
|
||||||
|
$this->assertSame(2, $row['box_num']);
|
||||||
|
$this->assertSame(1, $row['tray_num']);
|
||||||
|
$this->assertSame('2.00', $row['box_price']);
|
||||||
|
$this->assertSame('10.00', $row['tray_price']);
|
||||||
|
$this->assertSame('14.00', $row['added_amount'], '2×2.00 + 1×10.00');
|
||||||
|
$this->assertSame(1, $row['order_count']);
|
||||||
|
$this->assertCount(1, $row['orders']);
|
||||||
|
|
||||||
|
$order = $row['orders'][0];
|
||||||
|
$this->assertStringStartsWith('SO', $order['order_no']);
|
||||||
|
$this->assertSame(2, $order['box_num']);
|
||||||
|
$this->assertSame(1, $order['tray_num']);
|
||||||
|
|
||||||
|
$this->assertSame(3, $containers[1]['box_num']);
|
||||||
|
$this->assertSame('6.00', $containers[1]['added_amount']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并记录修改:逐笔更新订单数量并重算附加金额与订单总金额,其他门店不受影响 */
|
||||||
|
public function test_update_container_syncs_order_amounts(): void
|
||||||
|
{
|
||||||
|
$this->seedContainerConfig();
|
||||||
|
[$purchase, , $stores] = $this->buildPurchase();
|
||||||
|
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||||
|
$this->assertSame('20.00', (string) $order->product_amount, '下单时应写入商品金额');
|
||||||
|
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||||
|
'orders' => [['order_id' => $order->id, 'box_num' => 3, 'tray_num' => 1]],
|
||||||
|
])->assertOk()->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$order->refresh();
|
||||||
|
$this->assertSame(3, $order->box_num);
|
||||||
|
$this->assertSame(1, $order->tray_num);
|
||||||
|
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
|
||||||
|
$this->assertSame('20.00', (string) $order->product_amount);
|
||||||
|
$this->assertSame('36.00', (string) $order->total_amount, '20.00 + 16.00');
|
||||||
|
|
||||||
|
$other = StoreOrderModel::where('store_id', $stores[1]->id)->first();
|
||||||
|
$this->assertSame(0, $other->box_num, '其他门店订单不受影响');
|
||||||
|
$this->assertSame('30.00', (string) $other->total_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采购单已完成:合并记录修改拒绝,数量与金额不变 */
|
||||||
|
public function test_update_container_rejected_when_purchase_completed(): void
|
||||||
|
{
|
||||||
|
$this->seedContainerConfig();
|
||||||
|
[$purchase, , $stores] = $this->buildPurchase();
|
||||||
|
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||||
|
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
|
||||||
|
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||||
|
'orders' => [['order_id' => $order->id, 'box_num' => 5, 'tray_num' => 5]],
|
||||||
|
])->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '采购单已完成,不允许修改周转框/托盘');
|
||||||
|
|
||||||
|
$this->assertSame(0, $order->fresh()->box_num, '被拒绝后数量不变');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交必须覆盖该门店全部订单;跨门店/跨采购单订单拒绝 */
|
||||||
|
public function test_update_container_rejects_incomplete_or_foreign_orders(): void
|
||||||
|
{
|
||||||
|
$this->seedContainerConfig();
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
|
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||||
|
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||||
|
|
||||||
|
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||||
|
foreach ([1, 2] as $qty) {
|
||||||
|
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
}
|
||||||
|
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||||
|
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
$purchase = PurchaseOrderModel::first();
|
||||||
|
$orders = StoreOrderModel::where('purchase_id', $purchase->id)->orderBy('id')->get();
|
||||||
|
$this->assertCount(2, $orders, '同一门店两笔订单');
|
||||||
|
|
||||||
|
// 只提交一笔 → 缺少另一笔,整批拒绝
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
|
||||||
|
'orders' => [['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0]],
|
||||||
|
])->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '提交不完整,缺少订单:' . $orders[1]->id);
|
||||||
|
|
||||||
|
// 混入其他门店订单(同一采购单另一门店)→ 拒绝
|
||||||
|
$otherStore = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
|
$this->actingAsMiniUser(UserModel::factory()->forStore($otherStore->id)->create());
|
||||||
|
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
$foreign = StoreOrderModel::where('store_id', $otherStore->id)->first();
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
|
||||||
|
'orders' => [
|
||||||
|
['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0],
|
||||||
|
['order_id' => $orders[1]->id, 'box_num' => 1, 'tray_num' => 0],
|
||||||
|
['order_id' => $foreign->id, 'box_num' => 1, 'tray_num' => 0],
|
||||||
|
],
|
||||||
|
])->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '包含不属于该采购单该门店的订单');
|
||||||
|
|
||||||
|
$this->assertSame(0, $orders[0]->fresh()->box_num, '被拒绝后数量不变');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数量为负/重复订单时校验失败 */
|
||||||
|
public function test_update_container_validates_negative_and_duplicate_orders(): void
|
||||||
|
{
|
||||||
|
$this->seedContainerConfig();
|
||||||
|
[$purchase, , $stores] = $this->buildPurchase();
|
||||||
|
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||||
|
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||||
|
'orders' => [['order_id' => $order->id, 'box_num' => -1, 'tray_num' => 0]],
|
||||||
|
])->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '周转框数量不能小于 0');
|
||||||
|
|
||||||
|
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||||
|
'orders' => [
|
||||||
|
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
|
||||||
|
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
|
||||||
|
],
|
||||||
|
])->assertJsonPath('success', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
|
* 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
|
||||||
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价)
|
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价),每包成本 6.00
|
||||||
*/
|
*/
|
||||||
private function seedAcceptedOrders(): ProductModel
|
private function seedAcceptedOrders(): ProductModel
|
||||||
{
|
{
|
||||||
@@ -27,7 +27,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
|||||||
$levelLow = CustomerLevelModel::factory()->create();
|
$levelLow = CustomerLevelModel::factory()->create();
|
||||||
$storeA = StoreModel::factory()->create(['level_id' => $level->id]);
|
$storeA = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
$storeB = StoreModel::factory()->create(['level_id' => $level->id]);
|
$storeB = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '6.00']);
|
||||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelLow->id, 'price' => 4.00]);
|
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelLow->id, 'price' => 4.00]);
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
|||||||
return $product;
|
return $product;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ订货金额 */
|
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ数量×成本价(预估成本) */
|
||||||
public function test_generate_aggregates_order_items_into_header(): void
|
public function test_generate_aggregates_order_items_into_header(): void
|
||||||
{
|
{
|
||||||
$this->seedAcceptedOrders();
|
$this->seedAcceptedOrders();
|
||||||
@@ -61,7 +61,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
|||||||
$this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status);
|
$this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status);
|
||||||
|
|
||||||
$this->assertSame('10.00', (string) $purchase->total_quantity, '3+3+4');
|
$this->assertSame('10.00', (string) $purchase->total_quantity, '3+3+4');
|
||||||
$this->assertSame('50.00', (string) $purchase->estimate_amount, '10 件 × 等级价 5.00');
|
$this->assertSame('60.00', (string) $purchase->estimate_amount, '10 件 × 每包成本 6.00');
|
||||||
$this->assertSame('0.00', (string) $purchase->actual_amount);
|
$this->assertSame('0.00', (string) $purchase->actual_amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ use App\Models\ProductPriceModel;
|
|||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
|
||||||
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序下单:等级价快照、服务端重算总价、取消限制、门店数据隔离
|
* 小程序下单:等级价快照、服务端重算总价、取消限制、门店数据隔离
|
||||||
@@ -133,23 +130,6 @@ class StoreOrderTest extends ProcurementTestCase
|
|||||||
$this->getJson('/mini/order')->assertJsonPath('data.total', 0);
|
$this->getJson('/mini/order')->assertJsonPath('data.total', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 种子化周转框/托盘单价配置(框 2.00、托盘 10.00) */
|
|
||||||
private function seedContainerConfig(): void
|
|
||||||
{
|
|
||||||
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
|
|
||||||
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
|
|
||||||
SysSiteConfigItemsModel::create([
|
|
||||||
'group_id' => $group->id,
|
|
||||||
'key' => $key,
|
|
||||||
'title' => $title,
|
|
||||||
'type' => 'InputNumber',
|
|
||||||
'values' => $value,
|
|
||||||
'sort' => 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
Cache::forget('site_config');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 造一笔指定状态的订单(商品 5.00 × 2 = 10.00) */
|
/** 造一笔指定状态的订单(商品 5.00 × 2 = 10.00) */
|
||||||
private function makeOrderWithStatus(int $status): StoreOrderModel
|
private function makeOrderWithStatus(int $status): StoreOrderModel
|
||||||
{
|
{
|
||||||
@@ -163,94 +143,6 @@ class StoreOrderTest extends ProcurementTestCase
|
|||||||
return $order;
|
return $order;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改周转框/托盘:自动重算附加金额与订单总金额 */
|
|
||||||
public function test_container_update_recalculates_amounts(): void
|
|
||||||
{
|
|
||||||
$this->seedContainerConfig();
|
|
||||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
|
||||||
$this->assertSame('10.00', (string) $order->product_amount, '下单时应写入商品金额');
|
|
||||||
|
|
||||||
$this->actingAsSysUser();
|
|
||||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 3, 'tray_num' => 1])
|
|
||||||
->assertOk()->assertJsonPath('success', true);
|
|
||||||
|
|
||||||
$order->refresh();
|
|
||||||
$this->assertSame(3, $order->box_num);
|
|
||||||
$this->assertSame(1, $order->tray_num);
|
|
||||||
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
|
|
||||||
$this->assertSame('10.00', (string) $order->product_amount);
|
|
||||||
$this->assertSame('26.00', (string) $order->total_amount, '10.00 + 16.00');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 已接单、采购中、配送中均可修改 */
|
|
||||||
public function test_container_update_allowed_in_editable_statuses(): void
|
|
||||||
{
|
|
||||||
$this->seedContainerConfig();
|
|
||||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
|
||||||
$this->actingAsSysUser();
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
StoreOrderModel::STATUS_SUMMARIZED,
|
|
||||||
StoreOrderModel::STATUS_DELIVERING,
|
|
||||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
|
||||||
] as $status) {
|
|
||||||
$order->update(['status' => $status]);
|
|
||||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 1, 'tray_num' => 0])
|
|
||||||
->assertOk()->assertJsonPath('success', true);
|
|
||||||
$this->assertSame('2.00', (string) $order->fresh()->added_amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 待接单、已完成、已取消不允许修改 */
|
|
||||||
public function test_container_update_rejected_in_other_statuses(): void
|
|
||||||
{
|
|
||||||
$this->seedContainerConfig();
|
|
||||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_PENDING);
|
|
||||||
$this->actingAsSysUser();
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
StoreOrderModel::STATUS_PENDING,
|
|
||||||
StoreOrderModel::STATUS_COMPLETED,
|
|
||||||
StoreOrderModel::STATUS_CANCELLED,
|
|
||||||
] as $status) {
|
|
||||||
$order->update(['status' => $status]);
|
|
||||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 5, 'tray_num' => 5])
|
|
||||||
->assertOk()->assertJsonPath('success', false);
|
|
||||||
}
|
|
||||||
$order->refresh();
|
|
||||||
$this->assertSame(0, $order->box_num, '被拒绝后数量不变');
|
|
||||||
$this->assertSame('10.00', (string) $order->total_amount, '被拒绝后总金额不变');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 历史订单未写商品金额时按「总额 - 附加」反推回写 */
|
|
||||||
public function test_container_update_heals_legacy_product_amount(): void
|
|
||||||
{
|
|
||||||
$this->seedContainerConfig();
|
|
||||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
|
||||||
$order->update(['product_amount' => 0]); // 模拟历史数据
|
|
||||||
|
|
||||||
$this->actingAsSysUser();
|
|
||||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 2, 'tray_num' => 0])
|
|
||||||
->assertOk()->assertJsonPath('success', true);
|
|
||||||
|
|
||||||
$order->refresh();
|
|
||||||
$this->assertSame('10.00', (string) $order->product_amount, '反推回写商品金额');
|
|
||||||
$this->assertSame('4.00', (string) $order->added_amount, '2×2.00');
|
|
||||||
$this->assertSame('14.00', (string) $order->total_amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 数量为负时校验失败 */
|
|
||||||
public function test_container_update_validates_negative_numbers(): void
|
|
||||||
{
|
|
||||||
$this->seedContainerConfig();
|
|
||||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
|
||||||
|
|
||||||
$this->actingAsSysUser();
|
|
||||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => -1, 'tray_num' => 0])
|
|
||||||
->assertJsonPath('success', false)
|
|
||||||
->assertJsonPath('msg', '周转框数量不能小于 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 软删除:仅已取消订单可由后台删除,删除后后台/小程序端均不可见 */
|
/** 软删除:仅已取消订单可由后台删除,删除后后台/小程序端均不可见 */
|
||||||
public function test_soft_delete_only_cancelled_orders(): void
|
public function test_soft_delete_only_cancelled_orders(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,15 +27,6 @@ export async function batchUpdateOrderStatus(ids: number[], status: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改周转框/周转托盘数量(自动重算附加金额与订单总金额,仅已接单/采购中/配送中可改) */
|
|
||||||
export async function updateOrderContainer(id: number, data: { box_num: number; tray_num: number }) {
|
|
||||||
return createAxios({
|
|
||||||
url: `/order/store/${id}/container`,
|
|
||||||
method: 'put',
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除订单 */
|
/** 删除订单 */
|
||||||
export async function deleteStoreOrder(id: number) {
|
export async function deleteStoreOrder(id: number) {
|
||||||
return createAxios({
|
return createAxios({
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import createAxios from '@/utils/request';
|
import createAxios from '@/utils/request';
|
||||||
|
import { downloadBlob } from '@/api/common/download.ts';
|
||||||
import type {
|
import type {
|
||||||
IPurchaseCell,
|
IPurchaseCell,
|
||||||
IPurchaseDetail,
|
IPurchaseDetail,
|
||||||
|
IPurchaseStoreSummary,
|
||||||
|
PurchaseExportType,
|
||||||
} from '@/domain/iPurchaseOrder.ts';
|
} from '@/domain/iPurchaseOrder.ts';
|
||||||
|
|
||||||
/** 订单商品参数修改 */
|
/** 订单商品参数修改 */
|
||||||
@@ -20,6 +23,13 @@ export interface PurchaseCellUpdateParams {
|
|||||||
weight?: number;
|
weight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 采购单周转框/托盘合并记录修改:单笔订单的框/托盘数量 */
|
||||||
|
export interface PurchaseContainerOrderParams {
|
||||||
|
order_id: number;
|
||||||
|
box_num: number;
|
||||||
|
tray_num: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 生成采购单 */
|
/** 生成采购单 */
|
||||||
export async function generatePurchase(purchase_date: string, order_ids?: number[]) {
|
export async function generatePurchase(purchase_date: string, order_ids?: number[]) {
|
||||||
@@ -64,3 +74,34 @@ export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellU
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 门店购买详情:采购单内某门店的采购汇总(按商品聚合) */
|
||||||
|
export async function getPurchaseStoreSummary(purchaseId: number, storeId: number) {
|
||||||
|
return createAxios<IPurchaseStoreSummary>({
|
||||||
|
url: `/purchase/order/${purchaseId}/store`,
|
||||||
|
method: 'get',
|
||||||
|
params: { store_id: storeId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采购单周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(自动重算附加金额与订单总金额) */
|
||||||
|
export async function updatePurchaseContainer(
|
||||||
|
purchaseId: number,
|
||||||
|
storeId: number,
|
||||||
|
orders: PurchaseContainerOrderParams[],
|
||||||
|
) {
|
||||||
|
return createAxios({
|
||||||
|
url: `/purchase/order/${purchaseId}/container/${storeId}`,
|
||||||
|
method: 'put',
|
||||||
|
data: { orders },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C2/C3 导出采购单(Excel 表格):type=all 全品类 / category 仅蔬果分类 */
|
||||||
|
export async function exportPurchase(id: number, type: PurchaseExportType = 'all') {
|
||||||
|
return downloadBlob(
|
||||||
|
`/purchase/order/${id}/export`,
|
||||||
|
{ type },
|
||||||
|
`采购单_${id}.xlsx`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,34 @@ export interface IPurchaseDetail {
|
|||||||
purchase: IPurchaseOrder;
|
purchase: IPurchaseOrder;
|
||||||
stores: { id: number; name: string }[];
|
stores: { id: number; name: string }[];
|
||||||
items: IPurchaseDetailRow[];
|
items: IPurchaseDetailRow[];
|
||||||
|
/** 周转框/托盘合并记录(按门店聚合) */
|
||||||
|
containers: IPurchaseContainerStore[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并记录中的底层门店订单(订单号 + 框/托盘数量) */
|
||||||
|
export interface IPurchaseContainerOrder {
|
||||||
|
order_id: number;
|
||||||
|
order_no: string;
|
||||||
|
box_num: number;
|
||||||
|
tray_num: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店周转框/托盘合并记录(该门店在本采购单下全部订单的合计) */
|
||||||
|
export interface IPurchaseContainerStore {
|
||||||
|
store_id: number;
|
||||||
|
store_name: string;
|
||||||
|
/** 周转框合计 */
|
||||||
|
box_num: number;
|
||||||
|
/** 周转托盘合计 */
|
||||||
|
tray_num: number;
|
||||||
|
/** 周转框单价(站点配置) */
|
||||||
|
box_price: string;
|
||||||
|
/** 周转托盘单价(站点配置) */
|
||||||
|
tray_price: string;
|
||||||
|
/** 附加金额合计 = 框合计×框单价 + 托盘合计×托盘单价 */
|
||||||
|
added_amount: string;
|
||||||
|
order_count: number;
|
||||||
|
orders: IPurchaseContainerOrder[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */
|
/** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */
|
||||||
@@ -73,8 +101,6 @@ export interface IPurchaseCellItem {
|
|||||||
remark: string;
|
remark: string;
|
||||||
/** 首图 */
|
/** 首图 */
|
||||||
image?: string;
|
image?: string;
|
||||||
/** 是否可编辑/同步(采购单进行中且订货单未锁定) */
|
|
||||||
editable: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单元格下钻数据(门店 + 商品 + 全部订货明细) */
|
/** 单元格下钻数据(门店 + 商品 + 全部订货明细) */
|
||||||
@@ -84,6 +110,28 @@ export interface IPurchaseCell {
|
|||||||
items: IPurchaseCellItem[];
|
items: IPurchaseCellItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 门店购买详情行(门店采购汇总,按商品聚合) */
|
||||||
|
export interface IPurchaseStoreItem {
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
/** 规格/包规 */
|
||||||
|
product_spec: string;
|
||||||
|
unit: string;
|
||||||
|
/** 单价(每包;多笔单价时为加权平均,保证 单价×数量=预计金额) */
|
||||||
|
price: string;
|
||||||
|
/** 数量(包数) */
|
||||||
|
quantity: number;
|
||||||
|
/** 预计金额 = Σ 明细 amount */
|
||||||
|
amount: string;
|
||||||
|
weight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店购买详情(采购单内某门店的采购汇总) */
|
||||||
|
export interface IPurchaseStoreSummary {
|
||||||
|
store: { id: number; name: string } | null;
|
||||||
|
items: IPurchaseStoreItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||||
0: { text: '进行中', color: 'processing' },
|
0: { text: '进行中', color: 'processing' },
|
||||||
3: { text: '已完成', color: 'success' },
|
3: { text: '已完成', color: 'success' },
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ export default interface IStoreOrder {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
items?: IStoreOrderItem[];
|
items?: IStoreOrderItem[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
purchase?: {
|
||||||
|
id: number;
|
||||||
|
purchase_no: string;
|
||||||
|
purchase_date: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||||
@@ -83,13 +89,3 @@ export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: strin
|
|||||||
4: { text: '已完成', color: 'success' },
|
4: { text: '已完成', color: 'success' },
|
||||||
9: { text: '已取消', color: 'error' },
|
9: { text: '已取消', color: 'error' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 待汇总预览行 */
|
|
||||||
export interface IOrderSummaryRow {
|
|
||||||
product_id: number;
|
|
||||||
product_name: string;
|
|
||||||
product_spec: string;
|
|
||||||
unit: string;
|
|
||||||
total_quantity: string;
|
|
||||||
store_count: number;
|
|
||||||
}
|
|
||||||
|
|||||||
+21
-98
@@ -6,7 +6,6 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
InputNumber,
|
|
||||||
message,
|
message,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@@ -27,7 +26,6 @@ import type IStoreOrder from '@/domain/iStoreOrder.ts';
|
|||||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||||
import {
|
import {
|
||||||
getStoreOrder,
|
getStoreOrder,
|
||||||
updateOrderContainer,
|
|
||||||
updateOrderStatus,
|
updateOrderStatus,
|
||||||
batchUpdateOrderStatus,
|
batchUpdateOrderStatus,
|
||||||
deleteStoreOrder,
|
deleteStoreOrder,
|
||||||
@@ -36,13 +34,11 @@ import { generatePurchase } from '@/api/purchase/order.ts';
|
|||||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||||
import type IStore from '@/domain/iStore.ts';
|
import type IStore from '@/domain/iStore.ts';
|
||||||
import AuthButton from '@/components/AuthButton';
|
import AuthButton from '@/components/AuthButton';
|
||||||
import { DeleteOutlined, SettingOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||||
|
import {PURCHASE_STATUS_MAP} from "@/domain/iPurchaseOrder.ts";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
|
|
||||||
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
|
* 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
|
||||||
@@ -73,11 +69,6 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
const [detail, setDetail] = useState<IStoreOrder | null>(null);
|
const [detail, setDetail] = useState<IStoreOrder | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
const [containerOpen, setContainerOpen] = useState(false);
|
|
||||||
const [containerOrder, setContainerOrder] = useState<IStoreOrder | null>(null);
|
|
||||||
const [containerSaving, setContainerSaving] = useState(false);
|
|
||||||
const [containerForm] = Form.useForm<{ box_num: number; tray_num: number }>();
|
|
||||||
|
|
||||||
/** 勾选行(批量流转/生成采购单用) */
|
/** 勾选行(批量流转/生成采购单用) */
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
const [batchOpen, setBatchOpen] = useState(false);
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
@@ -146,25 +137,6 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openContainer = (record: IStoreOrder) => {
|
|
||||||
setContainerOrder(record);
|
|
||||||
containerForm.setFieldsValue({ box_num: record.box_num, tray_num: record.tray_num });
|
|
||||||
setContainerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContainerSave = async (values: { box_num: number; tray_num: number }) => {
|
|
||||||
if (!containerOrder?.id) return;
|
|
||||||
setContainerSaving(true);
|
|
||||||
try {
|
|
||||||
await updateOrderContainer(containerOrder.id, values);
|
|
||||||
message.success('周转框/托盘数量已更新');
|
|
||||||
setContainerOpen(false);
|
|
||||||
await tableRef.current?.reload();
|
|
||||||
} finally {
|
|
||||||
setContainerSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 删除订单(软删除,仅已取消订单可删;删除后后台/小程序端均不可见) */
|
/** 删除订单(软删除,仅已取消订单可删;删除后后台/小程序端均不可见) */
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
await deleteStoreOrder(id);
|
await deleteStoreOrder(id);
|
||||||
@@ -173,13 +145,6 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
await tableRef.current?.reload();
|
await tableRef.current?.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 弹窗内实时预览:附加金额 = 框×单价 + 托盘×单价;订单总金额 = 商品金额 + 附加金额
|
|
||||||
const watchBoxNum = Number(Form.useWatch('box_num', containerForm) ?? 0);
|
|
||||||
const watchTrayNum = Number(Form.useWatch('tray_num', containerForm) ?? 0);
|
|
||||||
const previewAdded = watchBoxNum * Number(containerOrder?.box_price ?? 0)
|
|
||||||
+ watchTrayNum * Number(containerOrder?.tray_price ?? 0);
|
|
||||||
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
|
|
||||||
|
|
||||||
const columns: XinTableColumn<IStoreOrder>[] = [
|
const columns: XinTableColumn<IStoreOrder>[] = [
|
||||||
{
|
{
|
||||||
title: '订单号',
|
title: '订单号',
|
||||||
@@ -344,10 +309,28 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '采购单ID',
|
title: '采购单',
|
||||||
dataIndex: 'purchase_id',
|
dataIndex: 'purchase_id',
|
||||||
valueType: 'digit',
|
valueType: 'digit',
|
||||||
hideInForm: true,
|
hideInForm: true,
|
||||||
|
render: (_, record) => record.purchase ? (
|
||||||
|
<Space orientation={'vertical'}>
|
||||||
|
<div>
|
||||||
|
<Text type={'secondary'}>采购单号:</Text>
|
||||||
|
{record.purchase.purchase_no}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text type={'secondary'}>采购单日期:</Text>
|
||||||
|
{record.purchase.purchase_date}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text type={'secondary'}>采购单状态:</Text>
|
||||||
|
<Tag color={PURCHASE_STATUS_MAP[record.purchase.status ?? 0]?.color}>
|
||||||
|
{PURCHASE_STATUS_MAP[record.purchase.status ?? 0]?.text}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
) : '-'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账单ID',
|
title: '账单ID',
|
||||||
@@ -368,15 +351,6 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
icon={<UnorderedListOutlined />}
|
icon={<UnorderedListOutlined />}
|
||||||
onClick={() => openDetail(record.id!)}
|
onClick={() => openDetail(record.id!)}
|
||||||
/>
|
/>
|
||||||
{ CONTAINER_EDITABLE_STATUS.includes(record.status ?? -1) && (
|
|
||||||
<AuthButton key="container" auth="order.store.update">
|
|
||||||
<Button
|
|
||||||
icon={<SettingOutlined />}
|
|
||||||
type={'primary'}
|
|
||||||
onClick={() => openContainer(record)}
|
|
||||||
/>
|
|
||||||
</AuthButton>
|
|
||||||
)}
|
|
||||||
{ NEXT_STATUS[record.status!] && (
|
{ NEXT_STATUS[record.status!] && (
|
||||||
<AuthButton auth="order.store.update">
|
<AuthButton auth="order.store.update">
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
@@ -468,13 +442,6 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
footer={
|
footer={
|
||||||
detail ? (
|
detail ? (
|
||||||
<Space className="flex justify-end" wrap>
|
<Space className="flex justify-end" wrap>
|
||||||
{CONTAINER_EDITABLE_STATUS.includes(detail.status ?? -1) && (
|
|
||||||
<AuthButton auth="order.store.update">
|
|
||||||
<Button icon={<SettingOutlined />} onClick={() => openContainer(detail)}>
|
|
||||||
设置附加信息
|
|
||||||
</Button>
|
|
||||||
</AuthButton>
|
|
||||||
)}
|
|
||||||
{ NEXT_STATUS[detail.status!] && (
|
{ NEXT_STATUS[detail.status!] && (
|
||||||
<AuthButton auth="order.store.update">
|
<AuthButton auth="order.store.update">
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
@@ -696,50 +663,6 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 修改周转框/托盘数量 */}
|
|
||||||
<Modal
|
|
||||||
title="修改周转框/托盘"
|
|
||||||
open={containerOpen}
|
|
||||||
onCancel={() => setContainerOpen(false)}
|
|
||||||
onOk={() => containerForm.submit()}
|
|
||||||
confirmLoading={containerSaving}
|
|
||||||
okText="保存"
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<div className="py-2 text-gray-500">
|
|
||||||
订单 {containerOrder?.order_no},保存后将按单价自动重算附加金额与订单总金额。
|
|
||||||
</div>
|
|
||||||
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
|
|
||||||
<Form.Item
|
|
||||||
label={`周转框数量(单价 ¥${containerOrder?.box_price ?? '0.00'})`}
|
|
||||||
name="box_num"
|
|
||||||
rules={[{ required: true, message: '请输入周转框数量' }]}
|
|
||||||
>
|
|
||||||
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转框数量" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={`周转托盘数量(单价 ¥${containerOrder?.tray_price ?? '0.00'})`}
|
|
||||||
name="tray_num"
|
|
||||||
rules={[{ required: true, message: '请输入周转托盘数量' }]}
|
|
||||||
>
|
|
||||||
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转托盘数量" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
<Space orientation="vertical" className="w-full rounded bg-gray-50 p-3">
|
|
||||||
<div>
|
|
||||||
<Text type="secondary">商品总金额:</Text>¥{containerOrder?.product_amount ?? '0.00'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text type="secondary">附加总金额:</Text>
|
|
||||||
<Text strong>¥{previewAdded.toFixed(2)}</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text type="secondary">订单总金额:</Text>
|
|
||||||
<Text strong type="danger">¥{previewTotal.toFixed(2)}</Text>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+477
-55
@@ -3,6 +3,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
Dropdown,
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
Image,
|
Image,
|
||||||
@@ -15,10 +16,11 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
import {DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
|
||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
import XinTable from '@/components/XinTable';
|
import XinTable from '@/components/XinTable';
|
||||||
import type {
|
import type {
|
||||||
@@ -30,15 +32,22 @@ import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
|
|||||||
import type {
|
import type {
|
||||||
IPurchaseCell,
|
IPurchaseCell,
|
||||||
IPurchaseCellItem,
|
IPurchaseCellItem,
|
||||||
|
IPurchaseContainerStore,
|
||||||
IPurchaseDetail,
|
IPurchaseDetail,
|
||||||
IPurchaseDetailRow,
|
IPurchaseDetailRow,
|
||||||
|
IPurchaseStoreItem,
|
||||||
|
IPurchaseStoreSummary,
|
||||||
} 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 { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||||
import {
|
import {
|
||||||
|
exportPurchase,
|
||||||
getPurchaseCell,
|
getPurchaseCell,
|
||||||
getPurchaseDetail, type PurchaseCellUpdateParams, type PurchaseRowUpdateParams,
|
getPurchaseDetail,
|
||||||
|
getPurchaseStoreSummary,
|
||||||
|
type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams,
|
||||||
updatePurchaseCellItem,
|
updatePurchaseCellItem,
|
||||||
|
updatePurchaseContainer,
|
||||||
updatePurchaseRow,
|
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';
|
||||||
@@ -48,10 +57,10 @@ import AuthButton from '@/components/AuthButton';
|
|||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
/** 参考单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
|
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
|
||||||
const calcUnitCost = (cost: number, spec: string): number => {
|
const calcUnitRefPrice = (total: number, spec: string): number => {
|
||||||
const pack = parseFloat(spec);
|
const pack = parseFloat(spec);
|
||||||
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost;
|
return Number.isFinite(pack) && pack > 0 ? total / pack : total;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +94,32 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
const [cellItemSaving, setCellItemSaving] = useState(false);
|
const [cellItemSaving, setCellItemSaving] = useState(false);
|
||||||
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
|
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
|
||||||
|
|
||||||
|
// 门店购买详情(按商品聚合的门店采购汇总)
|
||||||
|
const [detailTab, setDetailTab] = useState('items');
|
||||||
|
const [storeId, setStoreId] = useState<number>(0);
|
||||||
|
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
|
||||||
|
const [storeLoading, setStoreLoading] = useState(false);
|
||||||
|
|
||||||
|
// 周转框/托盘合并记录修改(按门店覆盖全部订单逐笔修改)
|
||||||
|
const [containerOpen, setContainerOpen] = useState(false);
|
||||||
|
const [containerStore, setContainerStore] = useState<IPurchaseContainerStore | null>(null);
|
||||||
|
const [containerSaving, setContainerSaving] = useState(false);
|
||||||
|
const [containerForm] = Form.useForm<{ orders: PurchaseContainerOrderParams[] }>();
|
||||||
|
|
||||||
|
// 弹窗内实时预览:合并合计 = Σ 各订单框/托盘数量,附加金额 = 合计 × 单价
|
||||||
|
const watchContainerOrders = Form.useWatch('orders', containerForm) ?? [];
|
||||||
|
const previewContainerBox = watchContainerOrders.reduce(
|
||||||
|
(sum, order) => sum + Number(order?.box_num ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const previewContainerTray = watchContainerOrders.reduce(
|
||||||
|
(sum, order) => sum + Number(order?.tray_num ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const previewContainerAdded =
|
||||||
|
previewContainerBox * Number(containerStore?.box_price ?? 0) +
|
||||||
|
previewContainerTray * Number(containerStore?.tray_price ?? 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -96,6 +131,20 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [cellOpen, cellQuery]);
|
}, [cellOpen, cellQuery]);
|
||||||
|
|
||||||
|
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
|
||||||
|
useEffect(() => {
|
||||||
|
if (detail && detail.stores.length > 0) {
|
||||||
|
setStoreId((prev) => (detail.stores.some((s) => s.id === prev) ? prev : detail.stores[0].id));
|
||||||
|
}
|
||||||
|
}, [detail]);
|
||||||
|
|
||||||
|
// 切到「门店购买详情」页签或切换门店时加载汇总
|
||||||
|
useEffect(() => {
|
||||||
|
if (detailOpen && detailTab === 'stores' && detail && storeId > 0) {
|
||||||
|
void loadStoreSummary();
|
||||||
|
}
|
||||||
|
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
|
||||||
|
|
||||||
const loadDetail = async (id: number) => {
|
const loadDetail = async (id: number) => {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -107,10 +156,26 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDetail = async (id: number) => {
|
const openDetail = async (id: number) => {
|
||||||
|
setDetailTab('items');
|
||||||
|
setStoreSummary(null);
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
await loadDetail(id);
|
await loadDetail(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 加载门店购买详情(门店采购汇总) */
|
||||||
|
const loadStoreSummary = async () => {
|
||||||
|
if (!detail || storeId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStoreLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getPurchaseStoreSummary(detail.purchase.id!, storeId);
|
||||||
|
setStoreSummary(res.data.data ?? null);
|
||||||
|
} finally {
|
||||||
|
setStoreLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
|
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
|
||||||
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
|
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
|
||||||
setCellQuery({ productId: row.product_id, storeId: store.id });
|
setCellQuery({ productId: row.product_id, storeId: store.id });
|
||||||
@@ -199,15 +264,42 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComplete = async () => {
|
/** 打开周转框/托盘合并记录修改(初始化该门店全部订单的当前数量) */
|
||||||
if (!detail) {
|
const openContainerEdit = (store: IPurchaseContainerStore) => {
|
||||||
|
setContainerStore(store);
|
||||||
|
containerForm.setFieldsValue({
|
||||||
|
orders: store.orders.map((order) => ({
|
||||||
|
order_id: order.order_id,
|
||||||
|
box_num: order.box_num,
|
||||||
|
tray_num: order.tray_num,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setContainerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 提交合并记录修改:逐笔更新订单,自动重算附加金额与订单总金额 */
|
||||||
|
const handleContainerSave = async (values: { orders: PurchaseContainerOrderParams[] }) => {
|
||||||
|
if (!detail || !containerStore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setContainerSaving(true);
|
||||||
|
try {
|
||||||
|
await updatePurchaseContainer(detail.purchase.id!, containerStore.store_id, values.orders);
|
||||||
|
message.success('周转框/托盘已更新,订单金额已重算');
|
||||||
|
setContainerOpen(false);
|
||||||
|
setContainerStore(null);
|
||||||
|
await loadDetail(detail.purchase.id!);
|
||||||
|
await tableRef.current?.reload();
|
||||||
|
} finally {
|
||||||
|
setContainerSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async (id: number) => {
|
||||||
setCompleting(true);
|
setCompleting(true);
|
||||||
try {
|
try {
|
||||||
await Update(`/purchase/order/${detail.purchase.id}`, { status: 3 });
|
await Update(`/purchase/order/${id}/finish`, { status: 3 });
|
||||||
message.success('采购单已标记完成');
|
message.success('采购单已标记完成');
|
||||||
await loadDetail(detail.purchase.id!);
|
|
||||||
await tableRef.current?.reload();
|
await tableRef.current?.reload();
|
||||||
} finally {
|
} finally {
|
||||||
setCompleting(false);
|
setCompleting(false);
|
||||||
@@ -226,11 +318,11 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
render: (_, row) => row.supplier?.name ?? '-',
|
render: (_, row) => row.supplier?.name ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '参考单价',
|
title: '参考成本单价',
|
||||||
key: 'unit_cost',
|
key: 'unit_cost',
|
||||||
width: 100,
|
width: 100,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => `¥${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
|
render: (_, row) => `¥${calcUnitRefPrice(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 || '-' },
|
||||||
@@ -267,16 +359,20 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<AuthButton auth="purchase.order.update">
|
<>
|
||||||
<Button
|
{ detail?.purchase.status === 0 ? (
|
||||||
size="small"
|
<AuthButton auth="purchase.order.update">
|
||||||
type="link"
|
<Button
|
||||||
icon={<EditOutlined />}
|
size="small"
|
||||||
onClick={() => openEdit(row)}
|
type="link"
|
||||||
>
|
icon={<EditOutlined />}
|
||||||
修改
|
onClick={() => openEdit(row)}
|
||||||
</Button>
|
>
|
||||||
</AuthButton>
|
修改
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
) : '-' }
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -317,20 +413,137 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 */
|
||||||
|
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
|
||||||
|
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
|
||||||
|
{
|
||||||
|
title: '参考零售价',
|
||||||
|
key: 'retail_price',
|
||||||
|
width: 130,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, row) => `¥${calcUnitRefPrice(Number(row.price), row.product_spec).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{ title: '包规', dataIndex: 'product_spec', width: 90, align: 'center', render: (v) => v || '-' },
|
||||||
|
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
|
||||||
|
{
|
||||||
|
title: '单价',
|
||||||
|
dataIndex: 'price',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: 90,
|
||||||
|
align: 'center',
|
||||||
|
render: (v) => <Text strong>{v}</Text>,
|
||||||
|
},
|
||||||
|
{ title: '重量', dataIndex: 'weight', width: 90, align: 'center', render: (v) => `${v || '-'}斤` },
|
||||||
|
{
|
||||||
|
title: '预计金额',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 门店购买详情合计行:总数量 + 总预计金额 */
|
||||||
|
const renderStoreTotal = () => {
|
||||||
|
const items = storeSummary?.items ?? [];
|
||||||
|
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
|
||||||
|
const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||||
|
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
|
||||||
|
return (
|
||||||
|
<Table.Summary.Row>
|
||||||
|
<Table.Summary.Cell index={0} colSpan={5} align="center">
|
||||||
|
<Text strong>合计</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={5} align="center">
|
||||||
|
<Text strong>{totalQuantity}</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={6} align="center">
|
||||||
|
<Text strong>{totalWeight.toFixed(3)}斤</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={7} align="center">
|
||||||
|
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */
|
||||||
|
const containerColumns: TableProps<IPurchaseContainerStore>['columns'] = [
|
||||||
|
{ title: '门店', dataIndex: 'store_name', width: 180, align: 'center' },
|
||||||
|
{ title: '订单数', dataIndex: 'order_count', width: 90, align: 'center' },
|
||||||
|
{
|
||||||
|
title: '周转框合计',
|
||||||
|
dataIndex: 'box_num',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Typography.Link onClick={() => openContainerEdit(row)}>{row.box_num}</Typography.Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '周转托盘合计',
|
||||||
|
dataIndex: 'tray_num',
|
||||||
|
width: 120,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Typography.Link onClick={() => openContainerEdit(row)}>{row.tray_num}</Typography.Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '附加金额',
|
||||||
|
dataIndex: 'added_amount',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, row) => <Text strong type="danger">¥{row.added_amount}</Text>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 周转框/托盘合并记录合计行 */
|
||||||
|
const renderContainerSummary = () => {
|
||||||
|
const containers = detail?.containers ?? [];
|
||||||
|
const totalBox = containers.reduce((sum, row) => sum + row.box_num, 0);
|
||||||
|
const totalTray = containers.reduce((sum, row) => sum + row.tray_num, 0);
|
||||||
|
const totalAdded = containers.reduce((sum, row) => sum + Number(row.added_amount), 0);
|
||||||
|
return (
|
||||||
|
<Table.Summary.Row>
|
||||||
|
<Table.Summary.Cell index={0} colSpan={2} align="center">
|
||||||
|
<Text strong>合计</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={2} align="center">
|
||||||
|
<Text strong>{totalBox}</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={3} align="center">
|
||||||
|
<Text strong>{totalTray}</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={4} align="center">
|
||||||
|
<Text strong type="danger">¥{totalAdded.toFixed(2)}</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const columns: XinTableColumn<IPurchaseOrder>[] = [
|
const columns: XinTableColumn<IPurchaseOrder>[] = [
|
||||||
{
|
{
|
||||||
title: '采购单号',
|
title: '采购单号',
|
||||||
dataIndex: 'purchase_no',
|
dataIndex: 'purchase_no',
|
||||||
valueType: 'text',
|
valueType: 'text',
|
||||||
hideInForm: true,
|
hideInForm: true,
|
||||||
|
align: 'center',
|
||||||
|
width: 260,
|
||||||
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
|
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '采购日期',
|
title: '采购日期',
|
||||||
dataIndex: 'purchase_date',
|
dataIndex: 'purchase_date',
|
||||||
valueType: 'dateRange',
|
valueType: 'date',
|
||||||
hideInForm: true,
|
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
hideInForm: true,
|
||||||
render: (_, record) => record.purchase_date,
|
render: (_, record) => record.purchase_date,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -338,15 +551,20 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
dataIndex: 'estimate_amount',
|
dataIndex: 'estimate_amount',
|
||||||
hideInForm: true,
|
hideInForm: true,
|
||||||
hideInSearch: true,
|
hideInSearch: true,
|
||||||
align: 'right',
|
align: 'center',
|
||||||
render: (_, record) => `¥${record.estimate_amount}`,
|
render: (_, record) => `¥${record.estimate_amount}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '实际成本',
|
title: '实际成本',
|
||||||
dataIndex: 'actual_amount',
|
dataIndex: 'actual_amount',
|
||||||
hideInForm: true,
|
|
||||||
hideInSearch: true,
|
hideInSearch: true,
|
||||||
align: 'right',
|
valueType: "digit",
|
||||||
|
fieldProps: {
|
||||||
|
min: 0,
|
||||||
|
precision: 3,
|
||||||
|
placeholder: "请输入单价"
|
||||||
|
},
|
||||||
|
align: 'center',
|
||||||
render: (_, record) =>
|
render: (_, record) =>
|
||||||
Number(record.actual_amount) > 0 ? (
|
Number(record.actual_amount) > 0 ? (
|
||||||
<Text strong>¥{record.actual_amount}</Text>
|
<Text strong>¥{record.actual_amount}</Text>
|
||||||
@@ -354,6 +572,28 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
<Text type="secondary">未录入</Text>
|
<Text type="secondary">未录入</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '总件数',
|
||||||
|
dataIndex: 'total_quantity',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'remark',
|
||||||
|
valueType: "textarea",
|
||||||
|
hideInTable: true,
|
||||||
|
hideInSearch: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '总重量',
|
||||||
|
dataIndex: 'total_weight',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, record) => `${record.total_weight}斤`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -381,10 +621,18 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
|
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record, dom) => [
|
||||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
record.status === 0 ? dom.edit : null,
|
||||||
详情
|
<Button key="detail" size="small" icon={<UnorderedListOutlined />} type={'primary'} onClick={() => openDetail(record.id!)} />,
|
||||||
</Button>
|
record.status === 0 ? (
|
||||||
|
<AuthButton auth="purchase.order.update">
|
||||||
|
<Popconfirm title="确认该采购单已完成?" description="已完成采购单将锁定,不能再修改订单信息" onConfirm={() => handleComplete(record.id!)}>
|
||||||
|
<Button type={'primary'} size="small" variant={'solid'} color={'green'} loading={completing}>
|
||||||
|
完成采购
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</AuthButton>
|
||||||
|
) : null
|
||||||
];
|
];
|
||||||
|
|
||||||
const tableProps: XinTableProps<IPurchaseOrder> = {
|
const tableProps: XinTableProps<IPurchaseOrder> = {
|
||||||
@@ -394,6 +642,9 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
accessName: 'purchase.order',
|
accessName: 'purchase.order',
|
||||||
tableRef,
|
tableRef,
|
||||||
operateRender,
|
operateRender,
|
||||||
|
operateProps: {
|
||||||
|
width: 300
|
||||||
|
},
|
||||||
formProps: false,
|
formProps: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -414,19 +665,10 @@ 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 && (
|
||||||
<>
|
<>
|
||||||
<Title level={5} className="mt-5! mb-3!">
|
<Title level={5} className="mb-3!">
|
||||||
采购单信息
|
采购单信息
|
||||||
</Title>
|
</Title>
|
||||||
<Descriptions column={3} size="small" bordered>
|
<Descriptions column={3} size="small" bordered>
|
||||||
@@ -439,24 +681,109 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
<Descriptions.Item label="制单人">
|
<Descriptions.Item label="制单人">
|
||||||
{detail.purchase.operator?.nickname ?? '-'}
|
{detail.purchase.operator?.nickname ?? '-'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="预估金额">¥{detail.purchase.estimate_amount}</Descriptions.Item>
|
<Descriptions.Item label="预估成本">¥{detail.purchase.estimate_amount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="实际金额">¥{detail.purchase.actual_amount}</Descriptions.Item>
|
<Descriptions.Item label="实际成本">¥{detail.purchase.actual_amount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="总件数">{detail.purchase.total_quantity}</Descriptions.Item>
|
||||||
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
|
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间" span={2}>{detail.purchase.created_at}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注" span={3}>{detail.purchase.remark}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Title level={5} className="mt-5! mb-3!">
|
<Tabs
|
||||||
商品明细
|
activeKey={detailTab}
|
||||||
</Title>
|
onChange={setDetailTab}
|
||||||
<Table<IPurchaseDetailRow>
|
className="mt-3!"
|
||||||
rowKey="product_id"
|
items={[
|
||||||
size="small"
|
{ key: 'items', label: '商品明细' },
|
||||||
bordered
|
{ key: 'stores', label: '门店购买详情' },
|
||||||
columns={buildItemColumns()}
|
{ key: 'containers', label: '周转框/托盘' },
|
||||||
dataSource={detail.items}
|
]}
|
||||||
pagination={false}
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
summary={renderSummary}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{ detailTab === 'stores' ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Text>选择门店:</Text>
|
||||||
|
<Select
|
||||||
|
value={storeId || undefined}
|
||||||
|
onChange={(value) => setStoreId(value)}
|
||||||
|
placeholder="选择门店"
|
||||||
|
className="w-60!"
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={detail.stores.map((s) => ({ value: s.id, label: s.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Spin spinning={storeLoading}>
|
||||||
|
{storeSummary && storeSummary.items.length > 0 ? (
|
||||||
|
<Table<IPurchaseStoreItem>
|
||||||
|
rowKey="product_id"
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
columns={storeColumns}
|
||||||
|
dataSource={storeSummary.items}
|
||||||
|
pagination={false}
|
||||||
|
summary={renderStoreTotal}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
!storeLoading && (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="该门店在此采购单中无采购商品"
|
||||||
|
className="py-8!"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</>
|
||||||
|
) : detailTab === 'containers' ? (
|
||||||
|
detail.containers.length > 0 ? (
|
||||||
|
<Table<IPurchaseContainerStore>
|
||||||
|
rowKey="store_id"
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
columns={containerColumns}
|
||||||
|
dataSource={detail.containers}
|
||||||
|
pagination={false}
|
||||||
|
summary={renderContainerSummary}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="该采购单暂无门店订单"
|
||||||
|
className="py-8!"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Space style={{ marginBottom: 20 }}>
|
||||||
|
<AuthButton key="export" auth="purchase.order.export">
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'all', label: '导出全品类', onClick: () => detail && exportPurchase(detail.purchase.id!, 'all') },
|
||||||
|
{ key: 'category', label: '仅导出蔬果分类', onClick: () => detail && exportPurchase(detail.purchase.id!, 'category') },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="primary" ghost icon={<DownloadOutlined />}>
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</AuthButton>
|
||||||
|
</Space>
|
||||||
|
<Table<IPurchaseDetailRow>
|
||||||
|
rowKey="product_id"
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
columns={buildItemColumns()}
|
||||||
|
dataSource={detail.items}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
summary={renderSummary}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) }
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -598,7 +925,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
{item.weight ?? '-'} 斤
|
{item.weight ?? '-'} 斤
|
||||||
</div>
|
</div>
|
||||||
<div className="w-40 shrink-0 text-center">
|
<div className="w-40 shrink-0 text-center">
|
||||||
{item.editable ? (
|
{detail?.purchase.status === 0 ? (
|
||||||
<Space size={0}>
|
<Space size={0}>
|
||||||
<AuthButton auth="purchase.order.update">
|
<AuthButton auth="purchase.order.update">
|
||||||
<Button
|
<Button
|
||||||
@@ -677,6 +1004,101 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 周转框/托盘合并记录下钻:按门店展示全部订单逐笔修改(采购单已完成则只读) */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
containerStore
|
||||||
|
? `${containerStore.store_name} · 周转框/托盘`
|
||||||
|
: '周转框/托盘'
|
||||||
|
}
|
||||||
|
open={containerOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setContainerOpen(false);
|
||||||
|
setContainerStore(null);
|
||||||
|
}}
|
||||||
|
onOk={() => containerForm.submit()}
|
||||||
|
confirmLoading={containerSaving}
|
||||||
|
okText="保存"
|
||||||
|
okButtonProps={{ disabled: detail?.purchase.status !== 0 }}
|
||||||
|
width={760}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<div className="py-2 text-gray-500">
|
||||||
|
该门店在本采购单下的全部订单({containerStore?.order_count ?? 0} 笔):
|
||||||
|
{detail?.purchase.status === 0
|
||||||
|
? '逐笔修改后保存,系统将自动重算每笔订单的附加金额与订单总金额。'
|
||||||
|
: '采购单已完成,仅可查看。'}
|
||||||
|
</div>
|
||||||
|
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
|
||||||
|
<Form.List name="orders">
|
||||||
|
{(fields) => (
|
||||||
|
<div className="overflow-hidden rounded border border-gray-200">
|
||||||
|
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
|
||||||
|
<div className="flex-1">订单号</div>
|
||||||
|
<div className="w-36 shrink-0 text-center">
|
||||||
|
周转框(单价 ¥{containerStore?.box_price ?? '0.00'})
|
||||||
|
</div>
|
||||||
|
<div className="w-36 shrink-0 text-center">
|
||||||
|
周转托盘(单价 ¥{containerStore?.tray_price ?? '0.00'})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fields.map((field) => {
|
||||||
|
const order = containerStore?.orders[field.name];
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
|
||||||
|
<div className="min-w-0 flex-1 pr-2 text-sm">
|
||||||
|
<Text copyable={{ text: order?.order_no ?? '' }}>{order?.order_no ?? '-'}</Text>
|
||||||
|
</div>
|
||||||
|
<Form.Item name={[field.name, 'order_id']} hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
className="m-0! w-36 shrink-0"
|
||||||
|
name={[field.name, 'box_num']}
|
||||||
|
rules={[{ required: true, message: '请输入周转框数量' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
className="w-full"
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
disabled={detail?.purchase.status !== 0}
|
||||||
|
placeholder="周转框数量"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
className="m-0! w-36 shrink-0"
|
||||||
|
name={[field.name, 'tray_num']}
|
||||||
|
rules={[{ required: true, message: '请输入周转托盘数量' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
className="w-full"
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
disabled={detail?.purchase.status !== 0}
|
||||||
|
placeholder="周转托盘数量"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form>
|
||||||
|
<Space orientation="vertical" className="mt-3! w-full rounded bg-gray-50 p-3">
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">周转框合计:</Text>
|
||||||
|
<Text strong>{previewContainerBox}</Text>
|
||||||
|
<Text type="secondary" className="ml-6!">周转托盘合计:</Text>
|
||||||
|
<Text strong>{previewContainerTray}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">附加金额合计:</Text>
|
||||||
|
<Text strong type="danger">¥{previewContainerAdded.toFixed(2)}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user