Files
xin-procurement/app/Http/Controllers/Mini/OrderController.php
T
2026-09-05 19:40:52 +08:00

369 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Mini\MiniOrderRequest;
use App\Models\BillModel;
use App\Models\CartModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Services\BillNumberService;
use App\Services\ItemImageResolver;
use App\Services\WeightEstimator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序门店订单
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class OrderController extends BaseMiniController
{
/**
* 下单
*/
#[PostRoute('/order', authorize: true)]
public function store(MiniOrderRequest $request): JsonResponse
{
$store = $this->currentStore($request);
// 截单时间校验:业务配置 services.order_time_start / order_time_end,均未配置时不限制
$this->assertWithinOrderTimeWindow();
$level = $store->level_id > 0 ? $store->level : null;
if ($level === null) {
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
}
// 回款周期校验:存在逾期未回款账单(未支付即拦截,含审核中)时禁止下单
// 周期 0 天=立即结清:任何未回款账单(含当天)都拦截;周期 N≥1:账单日 + N 天 < 今天(超过回款周期)才拦截
$cycleDays = (int) $store->payment_cycle_days;
$overdueQuery = BillModel::query()
->where('store_id', $store->id)
->where('status', BillModel::STATUS_UNPAID);
if ($cycleDays > 0) {
$overdueQuery->whereDate('bill_date', '<', now()->subDays($cycleDays)->toDateString());
}
$overdue = $overdueQuery
->selectRaw('COUNT(*) as aggregate_count, COALESCE(SUM(total_amount), 0) as aggregate_amount')
->first();
if ((int) $overdue->aggregate_count > 0) {
$amountText = bcadd((string) $overdue->aggregate_amount, '0', 2);
throw new RepositoryException($cycleDays > 0
? '您有 ' . (int) $overdue->aggregate_count . ' 笔账单已超过回款周期未回款(合计 ¥' . $amountText . '),请先结清后再下单'
: '您有 ' . (int) $overdue->aggregate_count . ' 笔账单未回款(合计 ¥' . $amountText . '),回款周期为当日结清,请先结清后再下单'
);
}
$items = $request->validated('items');
$remark = (string) ($request->validated('remark') ?? '');
$order = DB::transaction(function () use ($store, $level, $items, $remark) {
$productIds = array_map(static fn ($row) => (int) $row['product_id'], $items);
$products = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->whereIn('id', $productIds)
->get()
->keyBy('id');
$totalQuantity = '0';
$totalAmount = '0';
$totalWeight = '0';
$now = now();
$rows = [];
foreach ($items as $row) {
$productId = (int) $row['product_id'];
$product = $products->get($productId);
if ($product === null) {
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
}
// 实际价(按门店等级上浮比例换算,$products 已含 cost_price
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
$quantity = (string) $row['quantity'];
$amount = bcmul($price, $quantity, 2);
// 参考重量 = 订货量 × 规格折算(仅作参考,实际称重以采购录入为准)
$weight = WeightEstimator::estimate((string) $product->spec, (string) $product->unit, $quantity);
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
$totalAmount = bcadd($totalAmount, $amount, 2);
$totalWeight = bcadd($totalWeight, $weight, 3);
$rows[] = [
'store_id' => $store->id,
'product_id' => $productId,
'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,
'price_unit' => $product->price_unit,
'image_ids' => implode(',', (array) $product->image_ids),
'content' => (string) $product->content,
'shelf_life' => (int) $product->shelf_life,
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'cost_price' => (string) $product->cost_price,
'remark' => '',
'created_at' => $now,
'updated_at' => $now,
];
}
$order = StoreOrderModel::create([
'order_no' => app(BillNumberService::class)->make('SO'),
'store_id' => $store->id,
'order_date' => $now->toDateString(),
'total_quantity' => $totalQuantity,
'total_weight' => $totalWeight,
'total_amount' => $totalAmount,
'status' => StoreOrderModel::STATUS_PENDING,
'remark' => $remark,
]);
foreach ($rows as &$itemRow) {
$itemRow['order_id'] = $order->id;
}
StoreOrderItemModel::insert($rows);
// 下单成功后自动清空购物车中已下单的商品
CartModel::query()
->where('store_id', $store->id)
->whereIn('product_id', $productIds)
->delete();
return $order;
});
return $this->success([
'id' => $order->id,
'order_no' => $order->order_no,
'total_amount' => $order->total_amount,
], '下单成功');
}
/**
* 历史订单:当前门店强制过滤
* ?status= 状态筛选;?start_date=&end_date= 订货日期区间(配合 /order/summary 周期下钻);
* ?page=&pageSize= 分页(pageSize 上限 50
* 行数据附带 status_name / can_cancel / 商品预览(前 3 条明细)/ item_count,完整明细走详情接口
*/
#[GetRoute('/order', authorize: true)]
public function index(Request $request): JsonResponse
{
$params = $request->validate([
'status' => 'nullable|integer|in:0,1,2,3,4,9',
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
'page' => 'nullable|integer|min:1',
'pageSize' => 'nullable|integer|min:1|max:50',
], [
'status.in' => '订单状态不正确',
'start_date.date_format' => '开始日期格式为 Y-m-d',
'end_date.date_format' => '结束日期格式为 Y-m-d',
'end_date.after_or_equal' => '结束日期不能早于开始日期',
'pageSize.max' => '每页数量最大 50',
]);
$store = $this->currentStore($request);
$query = StoreOrderModel::query()
->where('store_id', $store->id)
->select([
'id', 'order_no', 'order_date', 'total_quantity', 'total_weight',
'total_amount', 'status', 'remark', 'purchase_id', 'bill_id', 'created_at',
])
->with(['items' => static fn ($itemsQuery) => $itemsQuery
->select(['id', 'order_id', 'product_name', 'product_spec', 'quantity', 'unit', 'image_ids'])
->orderBy('id')]);
if (isset($params['status'])) {
$query->where('status', (int) $params['status']);
}
if (! empty($params['start_date'])) {
$query->whereDate('order_date', '>=', $params['start_date']);
}
if (! empty($params['end_date'])) {
$query->whereDate('order_date', '<=', $params['end_date']);
}
$paginator = $query->orderBy('order_date', 'desc')
->orderBy('id', 'desc')
->paginate((int) ($params['pageSize'] ?? 10));
// 商品预览(每单前 3 条明细):首图跨订单一次性批量解析
$previewMap = [];
$flatItems = [];
foreach ($paginator->getCollection() as $order) {
foreach ($order->items->take(3) as $item) {
$flatItems[] = [
'order_id' => $order->id,
'product_name' => $item->product_name,
'product_spec' => $item->product_spec,
'quantity' => $item->quantity,
'unit' => $item->unit,
'image_ids' => (array) $item->image_ids,
];
}
}
app(ItemImageResolver::class)->resolve($flatItems);
foreach ($flatItems as $flatItem) {
$orderId = $flatItem['order_id'];
unset($flatItem['order_id']);
$previewMap[$orderId][] = $flatItem;
}
$paginator->getCollection()->transform(
static fn (StoreOrderModel $order): array => [
'id' => $order->id,
'order_no' => $order->order_no,
'order_date' => $order->order_date->toDateString(),
'status' => $order->status,
'status_name' => StoreOrderModel::STATUS_NAMES[$order->status] ?? '',
'can_cancel' => $order->status === StoreOrderModel::STATUS_PENDING,
'total_quantity' => $order->total_quantity,
'total_weight' => $order->total_weight,
'total_amount' => $order->total_amount,
'remark' => $order->remark,
'purchase_id' => $order->purchase_id,
'bill_id' => $order->bill_id,
'created_at' => $order->created_at?->toDateTimeString(),
'item_count' => $order->items->count(),
'items' => $previewMap[$order->id] ?? [],
]
);
return $this->success($paginator->toArray());
}
/**
* 按周期聚合金额/数量:?period=day|week|month(分组列表,period_label 可作下钻查询参数)
*/
#[GetRoute('/order/summary', authorize: true)]
public function summary(Request $request): JsonResponse
{
$period = (string) $request->query('period', 'month');
if (! in_array($period, ['day', 'week', 'month'], true)) {
throw new RepositoryException('period 参数只能是 day/week/month');
}
$store = $this->currentStore($request);
// 按数据库方言选择周期分组表达式(生产 MySQL / 测试 SQLite
$driver = DB::connection()->getDriverName();
if ($driver === 'sqlite') {
$format = match ($period) {
'day' => '%Y-%m-%d',
'week' => '%Y-W%W',
default => '%Y-%m',
};
$labelExpr = "strftime('{$format}', order_date)";
} else {
$format = match ($period) {
'day' => '%Y-%m-%d',
'week' => '%x-W%v',
default => '%Y-%m',
};
$labelExpr = "DATE_FORMAT(order_date, '{$format}')";
}
$rows = StoreOrderModel::query()
->where('store_id', $store->id)
->where('status', '<>', StoreOrderModel::STATUS_CANCELLED)
->selectRaw("{$labelExpr} as period_label")
->selectRaw('SUM(total_amount) as total_amount, SUM(total_quantity) as total_quantity, COUNT(*) as order_count')
->groupBy('period_label')
->orderByDesc('period_label')
->limit(50)
->get()
->toArray();
return $this->success(['period' => $period, 'groups' => $rows]);
}
/** 订单详情(校验归属:仅能查看本店订单) */
#[GetRoute('/order/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$store = $this->currentStore($request);
$order = StoreOrderModel::with('items')
->where('store_id', $store->id)
->find($id);
if ($order === null) {
throw new RepositoryException('订单不存在');
}
return $this->success($order->toArray());
}
/** 取消订单(仅待汇总可取消) */
#[PutRoute('/order/{id}/cancel', authorize: true, where: ['id' => '[0-9]+'])]
public function cancel(int $id, Request $request): JsonResponse
{
$store = $this->currentStore($request);
$order = StoreOrderModel::where('store_id', $store->id)->find($id);
if ($order === null) {
throw new RepositoryException('订单不存在');
}
if ($order->status !== StoreOrderModel::STATUS_PENDING) {
throw new RepositoryException('仅待汇总的订单可以取消');
}
$order->status = StoreOrderModel::STATUS_CANCELLED;
$order->save();
return $this->success([], '订单已取消');
}
/**
* 截单时间校验:业务配置 services.order_time_start / order_time_endHH:mm
* 均留空不限制;只配一端按单边限制;开始时间晚于截单时间表示跨天时段(如 20:00-次日06:00);
* 格式非法的配置按未配置处理,避免误配置导致全天无法下单
*/
private function assertWithinOrderTimeWindow(): void
{
$parse = static function (mixed $value): ?int {
$value = trim((string) $value);
if (! preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $value, $matches)) {
return null;
}
return (int) $matches[1] * 60 + (int) $matches[2];
};
$startText = trim((string) site_config('services.order_time_start', ''));
$endText = trim((string) site_config('services.order_time_end', ''));
$start = $parse($startText);
$end = $parse($endText);
if ($start === null && $end === null) {
return;
}
if ($start !== null && $start === $end) {
return;
}
$now = (int) now()->format('H') * 60 + (int) now()->format('i');
$allowed = match (true) {
// 跨天时段:当晚开始时间之后 或 次日截单时间之前
$start !== null && $end !== null && $start > $end => $now >= $start || $now <= $end,
$start !== null && $end !== null => $now >= $start && $now <= $end,
$start !== null => $now >= $start,
default => $now <= $end,
};
if (! $allowed) {
$window = ($startText !== '' ? $startText : '00:00') . ' - ' . ($endText !== '' ? $endText : '24:00');
throw new RepositoryException('当前不在下单时段内(下单时间 ' . $window . '),请在规定时间内下单');
}
}
}