920 lines
37 KiB
PHP
920 lines
37 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Purchase;
|
|
|
|
use App\Exceptions\RepositoryException;
|
|
use App\Exports\PurchaseOrderExport;
|
|
use App\Exports\PurchaseStoreExport;
|
|
use App\Exports\PurchaseSupplierExport;
|
|
use App\Http\Requests\Purchase\PurchaseBillGenerateRequest;
|
|
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
|
|
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
|
|
use App\Http\Requests\Purchase\PurchaseStoreItemRequest;
|
|
use App\Models\BillModel;
|
|
use App\Models\CustomerLevelModel;
|
|
use App\Models\ProductModel;
|
|
use App\Models\PurchaseItemCheckModel;
|
|
use App\Models\PurchaseOrderModel;
|
|
use App\Models\StoreModel;
|
|
use App\Models\StoreOrderItemModel;
|
|
use App\Models\StoreOrderModel;
|
|
use App\Models\SupplierModel;
|
|
use App\Services\BillGenerateService;
|
|
use App\Services\ItemImageResolver;
|
|
use App\Services\PurchaseGenerateService;
|
|
use App\Services\PurchaseItemService;
|
|
use App\Services\WeightEstimator;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Maatwebsite\Excel\Facades\Excel;
|
|
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
|
use Modules\AnnoRoute\Attribute\PutRoute;
|
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
|
use Modules\Common\Http\Controllers\BaseController;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Throwable;
|
|
|
|
/**
|
|
* 采购单管理
|
|
*/
|
|
#[RequestAttribute('/purchase/order', 'purchase.order')]
|
|
class PurchaseOrderController extends BaseController
|
|
{
|
|
protected array $searchField = [
|
|
'status' => '=',
|
|
'purchase_no' => 'like',
|
|
'purchase_date' => 'betweenDate',
|
|
];
|
|
|
|
/** 采购单列表 */
|
|
#[GetRoute(authorize: 'query')]
|
|
public function query(Request $request): JsonResponse
|
|
{
|
|
$params = $request->all();
|
|
$pageSize = $params['pageSize'] ?? 10;
|
|
$data = $this->buildSearch($params, PurchaseOrderModel::query()
|
|
->with('operator:id,nickname')
|
|
// 应付商品金额 = Σ 已生成门店账单的商品金额(实际口径;未生成账单为 null)
|
|
->withSum('bills as bill_product_amount', 'product_amount'))
|
|
->orderBy('purchase_date', 'desc')
|
|
->orderBy('id', 'desc')
|
|
->paginate($pageSize)
|
|
->toArray();
|
|
return $this->success($data);
|
|
}
|
|
|
|
/**
|
|
* 采购单详情
|
|
*/
|
|
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
|
|
public function detail(int $id): JsonResponse
|
|
{
|
|
$purchase = PurchaseOrderModel::with('operator:id,nickname')->find($id);
|
|
if (empty($purchase)) {
|
|
return $this->error('采购单不存在');
|
|
}
|
|
|
|
// 订货明细
|
|
$orderItems = StoreOrderItemModel::query()
|
|
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
|
->where('store_order_item.purchase_id', $purchase->id)
|
|
->whereNull('store_order.deleted_at')
|
|
->with('supplier:id,name')
|
|
->select('store_order_item.*')
|
|
->get()
|
|
->makeVisible('cost_price');
|
|
|
|
// 门店列(含软删除门店,保证历史单据可见)
|
|
$stores = StoreModel::withTrashed()
|
|
->whereIn('id', $orderItems->pluck('store_id')->unique())
|
|
->get(['id', 'name'])
|
|
->map(static fn (StoreModel $store) => ['id' => $store->id, 'name' => $store->name])
|
|
->values();
|
|
|
|
// 排序键:分类 sort → 商品 sort
|
|
$products = ProductModel::withTrashed()
|
|
->with('category:id,sort')
|
|
->whereIn('id', $orderItems->pluck('product_id')->unique())
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$rows = [];
|
|
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
|
|
$first = $group->first();
|
|
// 商品
|
|
$product = $products->get((int) $productId);
|
|
// 采购总数
|
|
$quantity = 0;
|
|
// 采购总重量
|
|
$weight = '0';
|
|
// 采购总金额(Σ明细 amount,单价按 金额÷数量 加权)
|
|
$amount = '0';
|
|
// 门店明细
|
|
$cellsMap = [];
|
|
|
|
foreach ($group as $item) {
|
|
$storeId = $item->store_id;
|
|
// 累加总量
|
|
$quantity += (int) $item->quantity;
|
|
$weight = bcadd($weight, (string) $item->weight, 3);
|
|
$amount = bcadd($amount, (string) $item->amount, 2);
|
|
|
|
// 按门店合并
|
|
if (!isset($cellsMap[$storeId])) {
|
|
$cellsMap[$storeId] = 0;
|
|
}
|
|
|
|
$cellsMap[$storeId] += (int) $item->quantity;
|
|
}
|
|
|
|
// 供应商
|
|
$supplier = $first->supplier ? ['id' => $first->supplier->id, 'name' => $first->supplier->name] : null;
|
|
$rows[] = [
|
|
'product_id' => (int) $productId,
|
|
'product_name' => $first->product_name, // 品名
|
|
'product_spec' => $first->product_spec, // 包规
|
|
'unit' => $first->unit, // 单位
|
|
'supplier_id' => (int) $first->supplier_id, // 供应商id
|
|
'supplier' => $supplier, // 供应商
|
|
'market' => (string) ($product?->market ?? ''), // 市场(取商品档案)
|
|
'cost_price' => $first->cost_price, // 成本价
|
|
'quantity' => $quantity,
|
|
'weight' => (float) $weight,
|
|
'amount' => $amount,
|
|
'cells' => $cellsMap,
|
|
'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']]);
|
|
|
|
// 门店账单:采购单完成后按门店生成(含软删除门店,保证历史单据可见)
|
|
$bills = BillModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->with('store:id,name')
|
|
->orderBy('id')
|
|
->get()
|
|
->map(static function (BillModel $bill): array {
|
|
$row = $bill->toArray();
|
|
$row['store_name'] = $bill->store->name ?? ('门店#' . $bill->store_id);
|
|
$row['order_count'] = $bill->orders()->count();
|
|
unset($row['store']);
|
|
return $row;
|
|
})
|
|
->values()
|
|
->toArray();
|
|
|
|
return $this->success([
|
|
'purchase' => $purchase->toArray(),
|
|
'stores' => $stores->toArray(),
|
|
'items' => array_map(static function (array $row): array {
|
|
unset($row['category_sort'], $row['product_sort']);
|
|
return $row;
|
|
}, $rows),
|
|
'bills' => $bills,
|
|
// 单品「已对账」标记(入库持久化,商品ID列表)
|
|
'checked_product_ids' => PurchaseItemCheckModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->pluck('product_id')
|
|
->map(static fn ($v) => (int) $v)
|
|
->all(),
|
|
]);
|
|
}
|
|
|
|
/** 修改采购单信息(采购日期、实际金额、备注) */
|
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
|
public function update(int $id, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'actual_amount' => 'required|numeric|min:0',
|
|
'remark' => 'nullable|string|max:255',
|
|
], [
|
|
'actual_amount.required' => '实际金额不能为空',
|
|
'actual_amount.numeric' => '实际金额格式错误',
|
|
'actual_amount.min' => '实际金额不能小于0',
|
|
'remark.max' => '备注超过最大长度',
|
|
]);
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
|
throw new RepositoryException('采购单已完成,不允许修改信息');
|
|
}
|
|
$purchase->update(array_filter($data, static fn ($v) => $v !== null));
|
|
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();
|
|
});
|
|
|
|
|
|
}
|
|
|
|
/**
|
|
* 生成采购单
|
|
*/
|
|
#[PostRoute('/generate', 'generate')]
|
|
public function generate(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'purchase_date' => 'required|date_format:Y-m-d',
|
|
'order_ids' => 'sometimes|array|min:1',
|
|
'order_ids.*' => 'integer|exists:store_order,id',
|
|
], [
|
|
'purchase_date.required' => '请选择采购日期',
|
|
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
|
|
'order_ids.min' => '请选择要合并的订单',
|
|
'order_ids.*.exists' => '订单不存在',
|
|
]);
|
|
|
|
$purchase = app(PurchaseGenerateService::class)->generate(
|
|
$data['purchase_date'],
|
|
(int) $request->user()->id,
|
|
array_map('intval', $data['order_ids'] ?? []),
|
|
);
|
|
|
|
return $this->success(
|
|
['id' => $purchase->id, 'purchase_no' => $purchase->purchase_no],
|
|
'采购单已生成'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 导出采购单商品明细 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('采购单不存在');
|
|
}
|
|
$supplierId = (int) $request->query('supplier_id', 0);
|
|
|
|
$filename = $purchase->purchase_no . '_采购单.xlsx';
|
|
if ($supplierId > 0) {
|
|
$supplier = SupplierModel::withTrashed()->find($supplierId);
|
|
$filename = $purchase->purchase_no . '_采购单_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx';
|
|
}
|
|
|
|
return Excel::download(new PurchaseOrderExport($purchase, $supplierId), $filename);
|
|
}
|
|
|
|
/**
|
|
* 导出门店购买详情(多工作表:每门店一个工作表;?store_id= 单门店导出)
|
|
*/
|
|
#[GetRoute(route: '/{id}/exportStores', authorize: 'export', where: ['id' => '[0-9]+'])]
|
|
public function exportStores(int $id, Request $request): Response
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
$storeId = (int) $request->query('store_id', 0);
|
|
|
|
$filename = $purchase->purchase_no . '_门店购买详情.xlsx';
|
|
if ($storeId > 0) {
|
|
$store = StoreModel::withTrashed()->find($storeId);
|
|
$filename = $purchase->purchase_no . '_门店购买详情_' . ($store->name ?? ('门店' . $storeId)) . '.xlsx';
|
|
}
|
|
|
|
return Excel::download(new PurchaseStoreExport($purchase, $storeId), $filename);
|
|
}
|
|
|
|
/**
|
|
* 导出供应商采购明细(多工作表:每供应商一个工作表;?supplier_id= 单供应商导出)
|
|
*/
|
|
#[GetRoute(route: '/{id}/exportSuppliers', authorize: 'export', where: ['id' => '[0-9]+'])]
|
|
public function exportSuppliers(int $id, Request $request): Response
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
$supplierId = (int) $request->query('supplier_id', 0);
|
|
|
|
$filename = $purchase->purchase_no . '_供应商采购明细.xlsx';
|
|
if ($supplierId > 0) {
|
|
$supplier = SupplierModel::withTrashed()->find($supplierId);
|
|
$filename = $purchase->purchase_no . '_供应商采购明细_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx';
|
|
}
|
|
|
|
return Excel::download(new PurchaseSupplierExport($purchase, $supplierId), $filename);
|
|
}
|
|
|
|
/**
|
|
* 单元格下钻:采购单中某门店某商品的全部订货明细
|
|
*/
|
|
#[GetRoute(route: '/{id}/cell', authorize: 'query', where: ['id' => '[0-9]+'])]
|
|
public function cell(int $id, Request $request): JsonResponse
|
|
{
|
|
$productId = (int) $request->query('product_id', 0);
|
|
$storeId = (int) $request->query('store_id', 0);
|
|
if ($productId <= 0 || $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.product_id', $productId)
|
|
->where('store_order_item.store_id', $storeId)
|
|
->whereNull('store_order.deleted_at')
|
|
->with('supplier:id,name')
|
|
->select(
|
|
'store_order_item.*',
|
|
'store_order.order_no',
|
|
'store_order.order_date',
|
|
'store_order.status as order_status',
|
|
)
|
|
->orderBy('store_order.id')
|
|
->get()
|
|
->makeVisible('cost_price');
|
|
|
|
$rows = [];
|
|
foreach ($items as $item) {
|
|
$rows[] = [
|
|
'id' => $item->id,
|
|
'order_id' => $item->order_id,
|
|
'order_no' => $item->order_no,
|
|
'order_date' => (string) $item->order_date,
|
|
'order_status' => (int) $item->order_status,
|
|
'product_name' => $item->product_name,
|
|
'product_spec' => $item->product_spec,
|
|
'unit' => $item->unit,
|
|
'supplier_id' => (int) $item->supplier_id,
|
|
'supplier' => $item->supplier ? ['id' => $item->supplier->id, 'name' => $item->supplier->name] : null,
|
|
'price' => (string) $item->price,
|
|
'cost_price' => (string) $item->cost_price,
|
|
'quantity' => (int) $item->quantity,
|
|
'weight' => (string) $item->weight,
|
|
'amount' => (string) $item->amount,
|
|
'remark' => (string) $item->remark,
|
|
'image_ids' => $item->image_ids,
|
|
];
|
|
}
|
|
app(ItemImageResolver::class)->resolve($rows);
|
|
|
|
$store = StoreModel::withTrashed()->find($storeId);
|
|
$product = ProductModel::withTrashed()->find($productId);
|
|
|
|
return $this->success([
|
|
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
|
|
'product' => $product ? ['id' => $product->id, 'name' => $product->name] : null,
|
|
'items' => $rows,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 门店购买详情:采购单内指定门店的采购汇总(按商品聚合)
|
|
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=预计金额
|
|
*/
|
|
#[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('采购单不存在');
|
|
}
|
|
|
|
$store = StoreModel::withTrashed()->find($storeId);
|
|
|
|
return $this->success([
|
|
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
|
|
'items' => app(PurchaseItemService::class)->storeSummaryRows($purchase->id, $storeId),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 门店购买详情:新增单品(挂靠该门店在采购单中的最新一笔订单,
|
|
* 单价按门店等级上浮比例换算,无等级按成本价兜底)
|
|
*
|
|
* @throws Throwable
|
|
*/
|
|
#[PostRoute(route: '/{id}/store/{storeId}/item', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])]
|
|
public function storeItemStore(int $id, int $storeId, PurchaseStoreItemRequest $request): JsonResponse
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
$this->assertPurchaseEditable($purchase);
|
|
|
|
$validated = $request->validated();
|
|
$productId = (int) $validated['product_id'];
|
|
$product = ProductModel::find($productId);
|
|
if ($product === null) {
|
|
throw new RepositoryException('商品不存在或已被删除,无法添加');
|
|
}
|
|
|
|
return DB::transaction(function () use ($purchase, $storeId, $product, $validated) {
|
|
// 该门店在采购单中的最新一笔订单
|
|
$order = StoreOrderModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->where('store_id', $storeId)
|
|
->orderByDesc('id')
|
|
->lockForUpdate()
|
|
->first();
|
|
if ($order === null) {
|
|
throw new RepositoryException('该门店不在此采购单中,无法添加单品');
|
|
}
|
|
|
|
$duplicated = StoreOrderItemModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->where('store_id', $storeId)
|
|
->where('product_id', $product->id)
|
|
->exists();
|
|
if ($duplicated) {
|
|
throw new RepositoryException('该商品已在此门店采购明细中,请直接修改数量');
|
|
}
|
|
|
|
// 单价:门店等级上浮换算价(无等级按成本价兜底)
|
|
$store = StoreModel::withTrashed()->find($storeId);
|
|
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
|
$price = $level !== null
|
|
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
|
: bcadd((string) $product->cost_price, '0', 2);
|
|
$quantity = (int) $validated['quantity'];
|
|
|
|
$item = StoreOrderItemModel::create([
|
|
'order_id' => $order->id,
|
|
'purchase_id' => $purchase->id,
|
|
'bill_id' => 0,
|
|
'store_id' => $storeId,
|
|
'product_id' => $product->id,
|
|
'category_id' => (int) $product->category_id,
|
|
'supplier_id' => (int) $product->supplier_id,
|
|
'product_name' => $product->name,
|
|
'product_spec' => $product->spec,
|
|
'unit' => (string) $product->unit,
|
|
'price' => $price,
|
|
'image_ids' => implode(',', (array) $product->image_ids),
|
|
'content' => (string) $product->content,
|
|
'shelf_life' => (int) $product->shelf_life,
|
|
'quantity' => $quantity,
|
|
// 未传称重时按订货量 × 规格预填参考重量
|
|
'weight' => isset($validated['weight'])
|
|
? bcadd((string) $validated['weight'], '0', 3)
|
|
: WeightEstimator::estimate((string) $product->spec, (string) $product->unit, (string) $quantity),
|
|
'amount' => bcmul($price, (string) $quantity, 2),
|
|
'cost_price' => (string) $product->cost_price,
|
|
'remark' => '',
|
|
]);
|
|
|
|
$service = app(PurchaseItemService::class);
|
|
$service->recalcOrder($order->id);
|
|
$service->recalcPurchase($purchase->id);
|
|
|
|
return $this->success(['id' => $item->id], '已添加单品');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 门店购买详情:修改单品(数量/称重/单价);同门店同商品多笔订单明细时合并到最早一条
|
|
*
|
|
* @throws Throwable
|
|
*/
|
|
#[PutRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])]
|
|
public function storeItemUpdate(int $id, int $storeId, int $productId, PurchaseStoreItemRequest $request): JsonResponse
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
$this->assertPurchaseEditable($purchase);
|
|
|
|
$validated = $request->validated();
|
|
|
|
return DB::transaction(function () use ($purchase, $storeId, $productId, $validated) {
|
|
$items = StoreOrderItemModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->where('store_id', $storeId)
|
|
->where('product_id', $productId)
|
|
->orderBy('id')
|
|
->lockForUpdate()
|
|
->get();
|
|
if ($items->isEmpty()) {
|
|
throw new RepositoryException('该门店在此采购单中无此商品明细');
|
|
}
|
|
|
|
$affectedOrderIds = $items->pluck('order_id')->unique()->all();
|
|
|
|
// 多笔订单明细合并到最早一条
|
|
$survivor = $items->first();
|
|
foreach ($items->skip(1) as $extra) {
|
|
$extra->delete();
|
|
}
|
|
|
|
$survivor->quantity = (int) $validated['quantity'];
|
|
if (isset($validated['price'])) {
|
|
$survivor->price = bcadd((string) $validated['price'], '0', 2);
|
|
}
|
|
if (isset($validated['weight'])) {
|
|
$survivor->weight = bcadd((string) $validated['weight'], '0', 3);
|
|
}
|
|
$survivor->amount = bcmul((string) $survivor->quantity, (string) $survivor->price, 2);
|
|
$survivor->save();
|
|
|
|
$service = app(PurchaseItemService::class);
|
|
foreach ($affectedOrderIds as $orderId) {
|
|
$service->recalcOrder((int) $orderId);
|
|
}
|
|
$service->recalcPurchase($purchase->id);
|
|
|
|
return $this->success([], '单品已更新');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 门店购买详情:移除单品(该门店此商品的全部订货明细一并删除)
|
|
*
|
|
* @throws Throwable
|
|
*/
|
|
#[DeleteRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])]
|
|
public function storeItemDelete(int $id, int $storeId, int $productId): JsonResponse
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
$this->assertPurchaseEditable($purchase);
|
|
|
|
DB::transaction(function () use ($purchase, $storeId, $productId) {
|
|
$items = StoreOrderItemModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->where('store_id', $storeId)
|
|
->where('product_id', $productId)
|
|
->lockForUpdate()
|
|
->get();
|
|
if ($items->isEmpty()) {
|
|
throw new RepositoryException('该门店在此采购单中无此商品明细');
|
|
}
|
|
|
|
$affectedOrderIds = $items->pluck('order_id')->unique()->all();
|
|
foreach ($items as $item) {
|
|
$item->delete();
|
|
}
|
|
|
|
$service = app(PurchaseItemService::class);
|
|
foreach ($affectedOrderIds as $orderId) {
|
|
$service->recalcOrder((int) $orderId);
|
|
}
|
|
$service->recalcPurchase($purchase->id);
|
|
});
|
|
|
|
return $this->success([], '已移除单品');
|
|
}
|
|
|
|
/**
|
|
* 账单生成预览
|
|
*/
|
|
#[GetRoute(route: '/{id}/bill/prepare', authorize: 'query', where: ['id' => '[0-9]+'])]
|
|
public function billPrepare(int $id): JsonResponse
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
|
|
$orders = StoreOrderModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->whereNull('deleted_at')
|
|
->orderBy('id')
|
|
->get(['id', 'store_id', 'total_amount']);
|
|
|
|
$stores = StoreModel::withTrashed()
|
|
->with('level:id,name,percent')
|
|
->whereIn('id', $orders->pluck('store_id')->unique())
|
|
->get(['id', 'name', 'level_id'])
|
|
->keyBy('id');
|
|
|
|
$bills = BillModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->get()
|
|
->keyBy('store_id');
|
|
|
|
$boxPrice = number_format((float) site_config('services.box_amount', 0), 2);
|
|
$trayPrice = number_format((float) site_config('services.tray_amount', 0), 2);
|
|
|
|
$rows = [];
|
|
foreach ($orders->groupBy('store_id') as $storeId => $storeOrders) {
|
|
$productAmount = $storeOrders->reduce(
|
|
static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
|
|
'0'
|
|
);
|
|
$bill = $bills->get((int) $storeId);
|
|
$store = $stores->get((int) $storeId);
|
|
$rows[] = [
|
|
'store_id' => (int) $storeId,
|
|
'store_name' => $store->name ?? ('门店#' . $storeId),
|
|
// 客户等级(售后金额上浮折算预览用;无等级=不上浮)
|
|
'level_name' => $store?->level?->name,
|
|
'level_percent' => (string) ($store?->level?->percent ?? '0'),
|
|
'order_count' => $storeOrders->count(),
|
|
'product_amount' => $productAmount,
|
|
'box_price' => $boxPrice,
|
|
'tray_price' => $trayPrice,
|
|
'bill' => $bill?->toArray(),
|
|
];
|
|
}
|
|
|
|
return $this->success([
|
|
'purchase' => $purchase->only(['id', 'purchase_no', 'status']),
|
|
'stores' => $rows,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 生成账单
|
|
* @throws Throwable
|
|
*/
|
|
#[PostRoute(route: '/{id}/bill', authorize: 'bill', where: ['id' => '[0-9]+'])]
|
|
public function generateBill(int $id, PurchaseBillGenerateRequest $request): JsonResponse
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
if ($purchase->status !== PurchaseOrderModel::STATUS_COMPLETED) {
|
|
throw new RepositoryException('采购单未完成,不允许生成账单');
|
|
}
|
|
|
|
$bills = app(BillGenerateService::class)->generate(
|
|
$purchase,
|
|
$request->validated()['stores'],
|
|
(int) $request->user()->id,
|
|
);
|
|
|
|
return $this->success(['count' => count($bills)], '已生成 ' . count($bills) . ' 张门店账单');
|
|
}
|
|
|
|
/**
|
|
* 商品行修改:品名/供应商/包规/单位/成本(同步更新商品档案;商品已删除则跳过档案同步)
|
|
* 成本价变化时,按各门店客户等级的上浮比例重算明细单价与金额(无等级按成本价兜底),
|
|
* 并级联重算涉及订单与采购单汇总
|
|
* @throws Throwable
|
|
*/
|
|
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
|
public function updateRow(int $id, int $productId, PurchaseRowUpdateRequest $request): JsonResponse
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
$this->assertPurchaseEditable($purchase);
|
|
|
|
$validated = $request->validated();
|
|
|
|
return DB::transaction(function () use ($purchase, $productId, $validated) {
|
|
$items = StoreOrderItemModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->where('product_id', $productId)
|
|
->lockForUpdate()
|
|
->get();
|
|
if ($items->isEmpty()) {
|
|
throw new RepositoryException('该采购单下无此商品的订货明细');
|
|
}
|
|
|
|
// 成本价是否变化(未变化时保留明细单价,避免覆盖单元格/单品的手工改价)
|
|
$newCost = bcadd((string) $validated['cost_price'], '0', 2);
|
|
$costChanged = bccomp($newCost, bcadd((string) $items->first()->cost_price, '0', 2), 2) !== 0;
|
|
|
|
// 成本变化 → 按门店等级上浮比例重算单价(一次性取回涉及门店的等级,避免逐行查询)
|
|
$levelPercents = [];
|
|
if ($costChanged) {
|
|
$levelPercents = StoreModel::withTrashed()
|
|
->with('level:id,percent')
|
|
->whereIn('id', $items->pluck('store_id')->unique())
|
|
->get(['id', 'level_id'])
|
|
->mapWithKeys(static fn (StoreModel $store) => [
|
|
$store->id => (float) ($store->level?->percent ?? 0),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
foreach ($items as $item) {
|
|
$item->fill($validated);
|
|
if ($costChanged) {
|
|
$item->price = CustomerLevelModel::calcLevelPrice(
|
|
$newCost,
|
|
$levelPercents[(int) $item->store_id] ?? 0,
|
|
);
|
|
$item->amount = bcmul($item->price, (string) $item->quantity, 2);
|
|
}
|
|
$item->save();
|
|
}
|
|
|
|
// 同步保存到商品档案(软删除商品跳过,明细照常更新)
|
|
$product = ProductModel::find($productId);
|
|
$productSynced = $product !== null;
|
|
if ($productSynced) {
|
|
$product->update([
|
|
'name' => $validated['product_name'],
|
|
'supplier_id' => $validated['supplier_id'],
|
|
'spec' => $validated['product_spec'],
|
|
'unit' => $validated['unit'],
|
|
'cost_price' => $validated['cost_price'],
|
|
]);
|
|
}
|
|
|
|
$service = app(PurchaseItemService::class);
|
|
// 成本变化会引起明细金额变动,涉及订单需逐一重算
|
|
if ($costChanged) {
|
|
foreach ($items->pluck('order_id')->unique() as $orderId) {
|
|
$service->recalcOrder((int) $orderId);
|
|
}
|
|
}
|
|
$service->recalcPurchase($purchase->id);
|
|
|
|
$message = '已同步 ' . $items->count() . ' 条订货明细'
|
|
. ($productSynced ? '与商品档案' : ';商品已删除,档案未同步')
|
|
. ($costChanged ? ';门店单价已按等级上浮比例重算' : '');
|
|
|
|
return $this->success(['count' => $items->count()], $message);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 门店订单明细修改(订货量、重量、单价),自动重算价格
|
|
* @throws Throwable
|
|
*/
|
|
#[PutRoute(route: '/cell/{itemId}', authorize: 'update', where: ['itemId' => '[0-9]+'])]
|
|
public function cellUpdate(int $itemId, PurchaseCellUpdateRequest $request): JsonResponse
|
|
{
|
|
$validated = $request->validated();
|
|
|
|
return DB::transaction(function () use ($itemId, $validated) {
|
|
$item = StoreOrderItemModel::query()->lockForUpdate()->find($itemId);
|
|
if (empty($item) || (int) $item->purchase_id === 0) {
|
|
throw new RepositoryException('采购单明细不存在');
|
|
}
|
|
$purchase = PurchaseOrderModel::find((int) $item->purchase_id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
$this->assertPurchaseEditable($purchase);
|
|
|
|
$item->quantity = $validated['quantity'];
|
|
$item->price = $validated['price'];
|
|
if (array_key_exists('weight', $validated)) {
|
|
$item->weight = bcadd((string) $validated['weight'], '0', 3);
|
|
}
|
|
$item->amount = bcmul((string) $validated['quantity'], (string) $validated['price'], 2);
|
|
$item->save();
|
|
|
|
$service = app(PurchaseItemService::class);
|
|
$service->recalcOrder((int) $item->order_id);
|
|
$service->recalcPurchase($purchase->id);
|
|
|
|
return $this->success();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 切换单品「已对账」标记(入库持久化:未标记→标记,已标记→取消;与采购单状态无关,对账期间可反复勾选)
|
|
*/
|
|
#[PutRoute(route: '/{id}/check/{productId}', authorize: 'query', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
|
public function toggleItemCheck(int $id, int $productId, Request $request): JsonResponse
|
|
{
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
$hasItem = StoreOrderItemModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->where('product_id', $productId)
|
|
->exists();
|
|
if (! $hasItem) {
|
|
throw new RepositoryException('该采购单下无此商品的订货明细');
|
|
}
|
|
|
|
$marked = PurchaseItemCheckModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->where('product_id', $productId)
|
|
->first();
|
|
if ($marked !== null) {
|
|
$marked->delete();
|
|
return $this->success(['checked' => false], '已取消对账标记');
|
|
}
|
|
|
|
PurchaseItemCheckModel::create([
|
|
'purchase_id' => $purchase->id,
|
|
'product_id' => $productId,
|
|
'operator_id' => (int) $request->user()->id,
|
|
]);
|
|
return $this->success(['checked' => true], '已标记为已对账');
|
|
}
|
|
|
|
/**
|
|
* 批量设置单品「已对账」标记(全选用:checked=true 批量标记 / false 批量取消;
|
|
* 仅处理本采购单内有订货明细的商品,重复标记幂等;与采购单状态无关)
|
|
*/
|
|
#[PutRoute(route: '/{id}/check', authorize: 'query', where: ['id' => '[0-9]+'])]
|
|
public function batchItemCheck(int $id, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'product_ids' => 'required|array|min:1',
|
|
'product_ids.*' => 'integer',
|
|
'checked' => 'required|boolean',
|
|
], [
|
|
'product_ids.required' => '请选择商品',
|
|
'product_ids.min' => '请选择商品',
|
|
]);
|
|
|
|
$purchase = PurchaseOrderModel::find($id);
|
|
if (empty($purchase)) {
|
|
throw new RepositoryException('采购单不存在');
|
|
}
|
|
|
|
// 仅处理本采购单内有订货明细的商品(无效商品静默忽略)
|
|
$productIds = StoreOrderItemModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->whereIn('product_id', array_map('intval', $data['product_ids']))
|
|
->distinct()
|
|
->pluck('product_id')
|
|
->map(static fn ($v) => (int) $v);
|
|
|
|
if (! (bool) $data['checked']) {
|
|
PurchaseItemCheckModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->whereIn('product_id', $productIds)
|
|
->delete();
|
|
return $this->success(['count' => $productIds->count()], '已取消对账标记');
|
|
}
|
|
|
|
// 批量标记:跳过已标记行,仅补插缺失行(幂等)
|
|
$marked = PurchaseItemCheckModel::query()
|
|
->where('purchase_id', $purchase->id)
|
|
->whereIn('product_id', $productIds)
|
|
->pluck('product_id')
|
|
->map(static fn ($v) => (int) $v);
|
|
$operatorId = (int) $request->user()->id;
|
|
$now = now();
|
|
$rows = $productIds->diff($marked)
|
|
->map(static fn (int $productId) => [
|
|
'purchase_id' => $purchase->id,
|
|
'product_id' => $productId,
|
|
'operator_id' => $operatorId,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
])
|
|
->values()
|
|
->all();
|
|
if ($rows !== []) {
|
|
PurchaseItemCheckModel::insert($rows);
|
|
}
|
|
|
|
return $this->success(['count' => $productIds->count()], '已标记为已对账');
|
|
}
|
|
|
|
/**
|
|
* 采购单编辑闸:仅进行中(待采购)允许修改明细
|
|
*/
|
|
private function assertPurchaseEditable(PurchaseOrderModel $purchase): void
|
|
{
|
|
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
|
throw new RepositoryException('采购单已完成,不允许修改明细');
|
|
}
|
|
}
|
|
}
|