首页分析页

This commit is contained in:
liu
2026-08-15 00:58:02 +08:00
parent 8368c51e3f
commit 7517d4a023
183 changed files with 1551 additions and 1510 deletions
@@ -0,0 +1,433 @@
<?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 App\Models\UserModel;
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, users: 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(),
'users' => UserModel::query()->where('status', UserModel::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);
}
}