CaiWuXiTong/modules/EcommerceFinance/Repositories/BillOffsetMirrorRepository.php

281 lines
10 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
/** 账单冲减只读共享业务镜像。 */
class BillOffsetMirrorRepository
{
public const DATASET = 'operations_replica.rpt.jst_originalBillInfo';
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
}
/**
* @return array{data:list<array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{amount:float,count:int},sync:array<string,mixed>}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$dataset = $this->datasets->get(self::DATASET);
if (! $this->mirror->ready($dataset)) {
throw new RuntimeException('bill_offset_mirror_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
// 只有店铺名称、订单号(内部订单号)和款式编码这几类过滤必须依赖关联表;
// 只按日期/单据号/商品编码查时,统计与分页都不必把千万级账单表和订单表
// 做全量关联,否则每次翻页都要扫一遍 ops_jst_orders。
$withJoin = $this->requiresJoin($filters);
$aggregate = $this->aggregate($filters, $withJoin);
$rows = $this->rows($filters, $page, $perPage, $withJoin);
$state = $this->mirror->state($dataset) ?? [];
return [
'data' => $rows,
'total' => $aggregate['total'],
'current_page' => $page,
'last_page' => max((int) ceil($aggregate['total'] / $perPage), 1),
'per_page' => $perPage,
'summary' => [
'amount' => $aggregate['amount'],
'count' => $aggregate['total'],
],
'sync' => [
'status' => $state['status'] ?? 'unknown',
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
],
];
}
/**
* 统计与分页以前各自跑一遍 count/sum 且都带着关联,
* 这里合并成一次聚合,并在不需要关联时退回单表。
*
* @return array{total:int,amount:float}
*/
private function aggregate(array $filters, bool $withJoin): array
{
$query = $withJoin ? $this->query() : $this->billQuery();
$this->applyBillOffsetScope($query);
$this->applyFilters($query, $filters);
$row = $query->selectRaw(
'COALESCE(SUM(COALESCE(b.amount, 0)), 0) AS amount, COUNT(*) AS aggregate_count'
)->first();
return [
'total' => (int) ($row->aggregate_count ?? 0),
'amount' => round((float) ($row->amount ?? 0), 2),
];
}
/**
* 取当前页行。不需要关联时先按 (fee_time, Id) 索引定位本页主键,
* 再只把这几行拿去关联订单/明细,避免整页数据参与大表 join + filesort。
*
* @return list<array<string,mixed>>
*/
private function rows(array $filters, int $page, int $perPage, bool $withJoin): array
{
$order = static function (Builder $query): void {
$query->orderByDesc('b.fee_time')->orderByDesc('b.Id');
};
if ($withJoin) {
$query = $this->query();
$this->applyBillOffsetScope($query);
$this->applyFilters($query, $filters);
$order($query);
return $query->select($this->columns())
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
}
$probe = $this->billQuery()->select('b.Id');
$this->applyBillOffsetScope($probe);
$this->applyFilters($probe, $filters);
$order($probe);
$ids = $probe->forPage($page, $perPage)->pluck('Id')->all();
if ($ids === []) {
return [];
}
$query = $this->query()->whereIn('b.Id', $ids);
$order($query);
return $query->select($this->columns())
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
}
/** 判断本组过滤条件是否有任何一项必须靠关联表才能成立。 */
private function requiresJoin(array $filters): bool
{
$orders = $this->ordersAvailable();
if ($orders && ($filters['shop_name'] ?? '') !== '') {
return true;
}
if ($orders && ($filters['order_no'] ?? '') !== '') {
return true;
}
return $orders && $this->orderItemsAvailable() && ($filters['sku_id'] ?? '') !== '';
}
private function billQuery(): Builder
{
return $this->mirror->connection()->table('ops_jst_original_bill_info as b');
}
private function query(): Builder
{
$query = $this->billQuery();
$ordersAvailable = $this->ordersAvailable();
if ($ordersAvailable) {
$query->leftJoin('ops_jst_orders as o', function ($join): void {
$join->on('o.so_id', '=', 'b.so_id');
});
}
if ($ordersAvailable && $this->orderItemsAvailable()) {
$query->leftJoin('ops_jst_order_item as i', function ($join): void {
$join->on('i.o_id', '=', 'o.o_id')
->whereColumn('i.sku_id', 'b.sku_id');
});
}
return $query;
}
private function hasTable(string $table): bool
{
return Schema::connection($this->mirror->connection()->getName())->hasTable($table);
}
private function ordersAvailable(): bool
{
return $this->hasTable('ops_jst_orders');
}
private function orderItemsAvailable(): bool
{
return $this->hasTable('ops_jst_order_item');
}
private function applyFilters(Builder $query, array $filters): void
{
if (($filters['from'] ?? '') !== '') {
$query->where('b.fee_time', '>=', $filters['from'] . ' 00:00:00');
}
if (($filters['to'] ?? '') !== '') {
$query->where('b.fee_time', '<', date('Y-m-d 00:00:00', strtotime($filters['to'] . ' +1 day')));
}
if (($filters['order_no'] ?? '') !== '') {
$needle = $this->contains((string) $filters['order_no']);
$query->where(function (Builder $order) use ($needle): void {
$order->whereRaw("CAST(b.so_id AS CHAR) LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("CAST(b.p_auto_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]);
if ($this->ordersAvailable()) {
$order->orWhereRaw("CAST(o.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]);
}
});
}
if (($filters['shop_name'] ?? '') !== '') {
if ($this->ordersAvailable()) {
$query->whereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains((string) $filters['shop_name'])]);
} else {
$query->whereRaw('1 = 0');
}
}
if (($filters['sku_id'] ?? '') !== '') {
$needle = $this->contains((string) $filters['sku_id']);
$query->where(function (Builder $sku) use ($needle): void {
foreach (['b.sku_id', 'b.shop_sku_id', 'b.shop_i_id'] as $column) {
$sku->orWhereRaw($column . " LIKE ? ESCAPE '!'", [$needle]);
}
if ($this->ordersAvailable() && $this->orderItemsAvailable()) {
$sku->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]);
}
});
}
}
private function applyBillOffsetScope(Builder $query): void
{
// 原始账单镜像供多个业务复用,这个页面只呈现 500112 冲减收入。
// 兼容上游历史上仅保存名称、不带科目编号的“冲减收入”写法。
$query->where(function (Builder $offset): void {
$offset->whereRaw("b.fee_name LIKE ? ESCAPE '!'", ['%500112%'])
->orWhereRaw("b.fee_name LIKE ? ESCAPE '!'", ['%冲减收入%']);
});
}
/** @return list<string> */
private function columns(): array
{
$columns = [
'b.Id as record_id', 'b.fee_time', 'b.fee_name', 'b.inout_type', 'b.amount',
'b.so_id as bill_order_no', 'b.sku_id as bill_product_code',
'b.shop_sku_id as bill_shop_product_code', 'b.shop_i_id as bill_shop_style_code',
];
if ($this->ordersAvailable()) {
$columns = [...$columns, 'o.o_id as internal_order_no', 'o.so_id as online_order_no', 'o.shop_name', 'o.shop_id as shop_code'];
}
if ($this->ordersAvailable() && $this->orderItemsAvailable()) {
$columns = [...$columns, 'i.sku_id as product_code', 'i.i_id as style_code', 'i.shop_sku_id as shop_product_code', 'i.shop_i_id as shop_style_code'];
}
return $columns;
}
/** @return array<string,mixed> */
private function mapRow(object $row): array
{
return [
'record_id' => (string) ($row->record_id ?? ''),
'fee_time' => $this->dateTime($row->fee_time ?? null),
'fee_name' => $row->fee_name ?? null,
'inout_type' => $row->inout_type ?? null,
'internal_order_no' => $row->internal_order_no ?? $row->bill_order_no ?? null,
'online_order_no' => $row->online_order_no ?? $row->bill_order_no ?? null,
'shop_name' => $row->shop_name ?? null,
'shop_code' => $row->shop_code ?? null,
'product_code' => $row->product_code ?? $row->bill_product_code ?? null,
'style_code' => $row->style_code ?? null,
'shop_product_code' => $row->shop_product_code ?? $row->bill_shop_product_code ?? null,
'shop_style_code' => $row->shop_style_code ?? $row->bill_shop_style_code ?? null,
'amount' => round((float) ($row->amount ?? 0), 2),
];
}
private function dateTime(mixed $value): string
{
return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value));
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
}