281 lines
10 KiB
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) . '%';
|
|
}
|
|
}
|