Files
xin-procurement/app/Http/Controllers/Dashboard/DashboardController.php
T

432 lines
16 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\Dashboard;
use App\Models\BillModel;
use App\Models\PaymentModel;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Models\SupplierModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 仪表盘统计(分析页 /dashboard/analysis 数据源)
*
* 口径约定:
* - 销售/订单/品类/热销等统计均排除「已取消」订单与软删除订单
* - 采购金额按实际采购价计(actual_amount 为 0 时退回预估价 estimate_amount
* - 应收余额 = 账单总额 - 已支付账单金额(审核中回款不计入已收)
*/
#[RequestAttribute('/dashboard', 'dashboard')]
class DashboardController extends BaseController
{
/** 销售趋势统计天数 */
private const int TREND_DAYS = 30;
/** 排行榜条数上限 */
private const int TOP_LIMIT = 10;
/** 最新订单条数 */
private const int LATEST_ORDER_LIMIT = 8;
/** 分析页聚合数据(单接口一次返回,减少首页请求数) */
#[GetRoute('/analysis', 'analysis')]
public function analysis(): JsonResponse
{
$today = now()->toDateString();
return $this->success([
'overview' => $this->overview(),
'trend' => $this->salesTrend($today),
'order_status' => $this->orderStatusDistribution(),
'category_sales' => $this->categorySales(),
'top_products' => $this->topProducts(),
'top_stores' => $this->topStores(),
'recon' => $this->reconSummary(),
'latest_orders' => $this->latestOrders(),
'archives' => $this->archives(),
]);
}
/**
* 核心指标卡:销售额 / 订单数 / 采购金额(累计、今日、近7天、环比、7日迷你趋势)+ 应收余额
*
* @return array{
* sales: array{total: float, today: float, week: float, growth: float|null, trend7: float[]},
* orders: array{total: int, today: int, week: int, growth: float|null, trend7: int[]},
* purchase: array{total: float, today: float, week: float, growth: float|null, trend7: float[]},
* receivable: array{bill_total: float, received: float, unreceived: float}
* }
*/
private function overview(): array
{
$today = now()->toDateString();
$weekStart = now()->subDays(6)->toDateString();
$lastWeekStart = now()->subDays(13)->toDateString();
$lastWeekEnd = now()->subDays(7)->toDateString();
// 近14天按日聚合(前7天用于环比,后7天用于迷你趋势图)
$daily = $this->validOrderQuery()
->whereBetween('order_date', [$lastWeekStart, $today])
->get(['order_date', 'total_amount'])
->groupBy(fn (StoreOrderModel $order) => $order->order_date->toDateString());
$salesByDay = [];
$ordersByDay = [];
foreach ($daily as $date => $orders) {
$salesByDay[$date] = (float) $orders->sum('total_amount');
$ordersByDay[$date] = $orders->count();
}
$salesWeek = $this->sumRange($salesByDay, $weekStart, $today);
$salesLastWeek = $this->sumRange($salesByDay, $lastWeekStart, $lastWeekEnd);
$ordersWeek = (int) $this->sumRange($ordersByDay, $weekStart, $today);
$ordersLastWeek = (int) $this->sumRange($ordersByDay, $lastWeekStart, $lastWeekEnd);
// 采购金额:实际价优先,无实际价用预估价
$purchaseDaily = PurchaseOrderModel::query()
->whereBetween('purchase_date', [$lastWeekStart, $today])
->get(['purchase_date', 'estimate_amount', 'actual_amount'])
->groupBy(fn (PurchaseOrderModel $purchase) => $purchase->purchase_date->toDateString())
->map(fn ($purchases) => (float) $purchases->sum(
fn (PurchaseOrderModel $purchase) => (float) $purchase->actual_amount > 0
? $purchase->actual_amount
: $purchase->estimate_amount
));
$purchaseWeek = $this->sumRange($purchaseDaily->all(), $weekStart, $today);
$purchaseLastWeek = $this->sumRange($purchaseDaily->all(), $lastWeekStart, $lastWeekEnd);
// 应收余额:账单总额 - 已支付账单金额
$billTotal = (float) BillModel::query()->sum('total_amount');
$received = (float) BillModel::query()->where('status', BillModel::STATUS_PAID)->sum('total_amount');
return [
'sales' => [
'total' => $this->money($this->validOrderQuery()->sum('total_amount')),
'today' => $this->money($salesByDay[$today] ?? 0),
'week' => $this->money($salesWeek),
'growth' => $this->growthRate($salesWeek, $salesLastWeek),
'trend7' => array_map(
fn ($day) => $this->money($salesByDay[$day] ?? 0),
$this->dateRange($weekStart, $today)
),
],
'orders' => [
'total' => (int) $this->validOrderQuery()->count(),
'today' => (int) ($ordersByDay[$today] ?? 0),
'week' => $ordersWeek,
'growth' => $this->growthRate((float) $ordersWeek, (float) $ordersLastWeek),
'trend7' => array_map(
static fn ($day) => (int) ($ordersByDay[$day] ?? 0),
$this->dateRange($weekStart, $today)
),
],
'purchase' => [
'total' => $this->money(PurchaseOrderModel::query()->sum(DB::raw(
'CASE WHEN actual_amount > 0 THEN actual_amount ELSE COALESCE(estimate_amount, 0) END'
))),
'today' => $this->money($purchaseDaily[$today] ?? 0),
'week' => $this->money($purchaseWeek),
'growth' => $this->growthRate($purchaseWeek, $purchaseLastWeek),
'trend7' => array_map(
fn ($day) => $this->money($purchaseDaily[$day] ?? 0),
$this->dateRange($weekStart, $today)
),
],
'receivable' => [
'bill_total' => $this->money($billTotal),
'received' => $this->money($received),
'unreceived' => $this->money($billTotal - $received),
],
];
}
/**
* 近30天销售趋势(缺日补0)
*
* @return array<int, array{date: string, amount: float, orders: int}>
*/
private function salesTrend(string $today): array
{
$start = now()->subDays(self::TREND_DAYS - 1)->toDateString();
$daily = $this->validOrderQuery()
->whereBetween('order_date', [$start, $today])
->get(['order_date', 'total_amount'])
->groupBy(fn (StoreOrderModel $order) => $order->order_date->toDateString());
return array_map(static function ($date) use ($daily) {
$orders = $daily->get($date);
return [
'date' => $date,
'amount' => round((float) ($orders?->sum('total_amount') ?? 0), 2),
'orders' => $orders?->count() ?? 0,
];
}, $this->dateRange($start, $today));
}
/**
* 订单状态分布(含已取消,全部状态固定输出,缺省补0)
*
* @return array<int, array{status: int, name: string, count: int}>
*/
private function orderStatusDistribution(): array
{
$counts = StoreOrderModel::query()
->selectRaw('status, COUNT(*) as cnt')
->groupBy('status')
->pluck('cnt', 'status');
$result = [];
foreach (StoreOrderModel::STATUS_NAMES as $status => $name) {
$result[] = [
'status' => $status,
'name' => $name,
'count' => (int) ($counts[$status] ?? 0),
];
}
return $result;
}
/**
* 品类销售占比(按明细金额汇总,金额降序)
*
* @return array<int, array{name: string, amount: float}>
*/
private function categorySales(): array
{
$rows = $this->validItemQuery()
->groupBy('i.category_id')
->selectRaw('i.category_id, SUM(i.amount) as amount')
->orderByDesc('amount')
->get();
$categoryNames = ProductCategoryModel::query()->pluck('name', 'id');
return $rows->map(static fn ($row) => [
'name' => $categoryNames[$row->category_id] ?? '未分类',
'amount' => round((float) $row->amount, 2),
])->all();
}
/**
* 热销商品 TOP10(按明细金额汇总;商品名/规格/单位取快照)
*
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, quantity: float, amount: float}>
*/
private function topProducts(): array
{
return $this->validItemQuery()
->groupBy('i.product_id')
->selectRaw(implode(', ', [
'i.product_id',
'MAX(i.product_name) as product_name',
'MAX(i.product_spec) as product_spec',
'MAX(i.unit) as unit',
'SUM(i.quantity) as quantity',
'SUM(i.amount) as amount',
]))
->orderByDesc('amount')
->limit(self::TOP_LIMIT)
->get()
->map(static fn ($row) => [
'product_id' => (int) $row->product_id,
'product_name' => (string) $row->product_name,
'product_spec' => (string) $row->product_spec,
'unit' => (string) $row->unit,
'quantity' => round((float) $row->quantity, 2),
'amount' => round((float) $row->amount, 2),
])
->all();
}
/**
* 门店排行 TOP10(按订单金额汇总,含软删除门店名称回查)
*
* @return array<int, array{store_id: int, store_name: string, order_count: int, amount: float}>
*/
private function topStores(): array
{
$rows = $this->validOrderQuery()
->groupBy('store_id')
->selectRaw('store_id, COUNT(*) as order_count, SUM(total_amount) as amount')
->orderByDesc('amount')
->limit(self::TOP_LIMIT)
->get();
$storeNames = StoreModel::withTrashed()
->whereIn('id', $rows->pluck('store_id'))
->pluck('name', 'id');
return $rows->map(static fn ($row) => [
'store_id' => (int) $row->store_id,
'store_name' => $storeNames[$row->store_id] ?? '未知门店',
'order_count' => (int) $row->order_count,
'amount' => round((float) $row->amount, 2),
])->all();
}
/**
* 对账与回款概览:账单支付进度分布 + 待审核回款
*
* @return array{
* bills: array<int, array{pay_state: int, name: string, count: int, amount: float}>,
* pending_payment: array{count: int, amount: float}
* }
*/
private function reconSummary(): array
{
$states = [];
foreach (BillModel::PAY_STATE_NAMES as $state => $name) {
$states[$state] = ['pay_state' => $state, 'name' => $name, 'count' => 0, 'amount' => 0.0];
}
BillModel::query()
->get(['id', 'total_amount', 'status', 'payment_id'])
->each(static function (BillModel $bill) use (&$states) {
$state = $bill->payState();
$states[$state]['count']++;
$states[$state]['amount'] += (float) $bill->total_amount;
});
$bills = array_values(array_map(
fn ($state) => [
'pay_state' => $state['pay_state'],
'name' => $state['name'],
'count' => $state['count'],
'amount' => $this->money($state['amount']),
],
$states
));
$pendingPayment = PaymentModel::query()->where('status', PaymentModel::STATUS_PENDING);
return [
'bills' => $bills,
'pending_payment' => [
'count' => (int) $pendingPayment->count(),
'amount' => $this->money($pendingPayment->sum('amount')),
],
];
}
/**
* 最新订单(按下单日期倒序,含已取消,便于掌握最新动态)
*
* @return array<int, array{id: int, order_no: string, store_name: string, order_date: string, total_amount: float, status: int, status_name: string}>
*/
private function latestOrders(): array
{
return StoreOrderModel::query()
->with('store:id,name')
->orderByDesc('order_date')
->orderByDesc('id')
->limit(self::LATEST_ORDER_LIMIT)
->get()
->map(static fn (StoreOrderModel $order) => [
'id' => $order->id,
'order_no' => $order->order_no,
'store_name' => $order->store->name ?? '未知门店',
'order_date' => $order->order_date->toDateString(),
'total_amount' => round((float) $order->total_amount, 2),
'status' => $order->status,
'status_name' => StoreOrderModel::STATUS_NAMES[$order->status] ?? (string) $order->status,
])
->all();
}
/**
* 基础档案计数:在营门店 / 在售商品 / 合作供应商
*
* @return array{stores: int, products: int, suppliers: int}
*/
private function archives(): array
{
return [
'stores' => StoreModel::query()->where('status', StoreModel::STATUS_NORMAL)->count(),
'products' => ProductModel::query()->where('status', ProductModel::STATUS_ON)->count(),
'suppliers' => SupplierModel::query()->where('status', SupplierModel::STATUS_NORMAL)->count(),
];
}
/** 有效订单查询(排除已取消;软删除由模型全局作用域排除) */
private function validOrderQuery(): Builder
{
return StoreOrderModel::query()->where('status', '<>', StoreOrderModel::STATUS_CANCELLED);
}
/**
* 有效订单明细查询(join 绕过模型全局作用域,需手动排除软删除订单与已取消订单)
*/
private function validItemQuery(): Builder
{
return StoreOrderItemModel::query()
->from('store_order_item as i')
->join('store_order as o', 'o.id', '=', 'i.order_id')
->whereNull('o.deleted_at')
->where('o.status', '<>', StoreOrderModel::STATUS_CANCELLED);
}
/**
* 按日期区间(含端点)汇总每日数据,缺日按0计
*
* @param array<string, float|int> $daily 日期 => 当日值
*/
private function sumRange(array $daily, string $start, string $end): float
{
$sum = 0.0;
foreach ($this->dateRange($start, $end) as $date) {
$sum += (float) ($daily[$date] ?? 0);
}
return $sum;
}
/**
* 生成连续日期列表(含端点)
*
* @return string[]
*/
private function dateRange(string $start, string $end): array
{
$dates = [];
$cursor = $start;
while ($cursor <= $end) {
$dates[] = $cursor;
$cursor = date('Y-m-d', strtotime($cursor . ' +1 day'));
}
return $dates;
}
/** 金额统一保留两位 */
private function money(float|string|null $value): float
{
return round((float) $value, 2);
}
/**
* 环比增长率(%):上期为0时无基准,返回 null
*/
private function growthRate(float $current, float $previous): ?float
{
if ($previous <= 0.0) {
return null;
}
return round(($current - $previous) / $previous * 100, 1);
}
}