CaiWuXiTong/modules/EcommerceFinance/Repositories/OrderProfitRepository.php
renshen 31de7272e3 feat: 订单利润支持支付发货时间筛选 (#983)
订单利润支持支付时间、发货时间、订单时间筛选;先分页订单键再汇总当前页,补充日期索引与性能优化。
2026-11-07 16:21:24 +08:00

590 lines
23 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 Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\GoodsProfitAfsLookupSchema;
/**
* 聚水潭「订单利润」页面数据,按订单聚合商品利润镜像明细。
*
* 商品成本、退款、退平台补贴等来自订单利润明细镜像;包材费、快递费和
* 账单费用属于聚水潭页面侧的自动匹配结果,源表中没有字段,未确认规则前
* 保持为空,不能用 0 代替。
*/
class OrderProfitRepository
{
private const TABLE = GoodsProfitAfsLookupSchema::TABLE;
private const NUMERIC_FIELDS = [
'product_quantity', 'product_amount', 'product_cost',
'actual_shipped_quantity', 'actual_shipped_amount', 'actual_shipped_cost',
'merchant_discount', 'freight_income', 'payment_amount', 'platform_discount',
'pre_application_quantity', 'pre_application_amount', 'pre_application_cost',
'pre_return_quantity', 'pre_return_amount', 'pre_return_cost',
'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy',
'post_application_quantity', 'post_application_amount', 'post_application_cost',
'post_return_quantity', 'post_return_amount', 'post_return_cost',
'post_refund_amount', 'post_refund_cost', 'post_platform_subsidy',
'defective_quantity',
];
private const SUMMARY_NUMERIC_FIELDS = [
'cost_amount' => ['product_cost'],
'product_quantity' => ['product_quantity'],
'product_amount' => ['product_amount'],
'actual_shipped_quantity' => ['actual_shipped_quantity'],
'actual_shipped_amount' => ['actual_shipped_amount'],
'actual_shipped_cost' => ['actual_shipped_cost'],
'merchant_discount' => ['merchant_discount'],
'freight_income' => ['freight_income'],
'payment_amount' => ['payment_amount'],
'platform_discount' => ['platform_discount'],
'refund_amount' => ['pre_refund_amount', 'post_refund_amount'],
'platform_subsidy' => ['pre_platform_subsidy', 'post_platform_subsidy'],
'pre_platform_subsidy' => ['pre_platform_subsidy'],
'pre_application_quantity' => ['pre_application_quantity'],
'pre_application_amount' => ['pre_application_amount'],
'pre_application_cost' => ['pre_application_cost'],
'pre_return_quantity' => ['pre_return_quantity'],
'pre_return_amount' => ['pre_return_amount'],
'pre_return_cost' => ['pre_return_cost'],
'pre_refund_amount' => ['pre_refund_amount'],
'pre_refund_cost' => ['pre_refund_cost'],
'post_platform_subsidy' => ['post_platform_subsidy'],
'post_application_quantity' => ['post_application_quantity'],
'post_application_amount' => ['post_application_amount'],
'post_application_cost' => ['post_application_cost'],
'post_return_quantity' => ['post_return_quantity'],
'post_return_amount' => ['post_return_amount'],
'post_return_cost' => ['post_return_cost'],
'post_refund_amount' => ['post_refund_amount'],
'post_refund_cost' => ['post_refund_cost'],
'defective_quantity' => ['defective_quantity'],
];
private const ITEM_FIELDS = [
'internal_order_no', 'online_order_no', 'sub_order_no', 'after_sales_no',
'pay_time', 'send_time', 'order_time', 'shop_name', 'product_code', 'style_code',
'shop_product_code', 'shop_style_code', 'product_name', 'color_spec',
'product_quantity', 'product_amount', 'product_cost',
'actual_shipped_quantity', 'actual_shipped_amount', 'actual_shipped_cost',
'merchant_discount', 'freight_income', 'payment_amount', 'platform_discount',
'refund_type', 'pre_application_quantity', 'pre_application_amount', 'pre_application_cost',
'pre_return_quantity', 'pre_return_amount', 'pre_return_cost',
'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy',
'post_application_quantity', 'post_application_amount', 'post_application_cost',
'post_return_quantity', 'post_return_amount', 'post_return_cost',
'post_refund_amount', 'post_refund_cost', 'post_platform_subsidy', 'defective_quantity',
'classification_order', 'combine_type', 'shipping_warehouse', 'express_no',
'express_company', 'order_tags', 'after_sales_tags', 'item_tags',
];
private ?bool $readyCache = null;
public function __construct(private readonly ?GoodsProfitAfsRepository $fields = null)
{
}
public function ready(): bool
{
return $this->readyCache ??= ($this->fields ?? app(GoodsProfitAfsRepository::class))->ready();
}
public function status(): array
{
try {
$state = $this->connection()->table('business_mirror_table_states')
->where('dataset_key', GoodsProfitAfsRepository::MAIN_DATASET)
->first(['initial_completed_at', 'last_succeeded_at', 'last_error_code']);
} catch (\Throwable) {
$state = null;
}
return [
'ready' => $this->ready(),
'initial_completed_at' => $state->initial_completed_at ?? null,
'last_succeeded_at' => $state->last_succeeded_at ?? null,
'last_error_code' => $state->last_error_code ?? null,
];
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
public function paginate(array $filters, int $page, int $limit): array
{
if (! $this->hasActiveFilters($filters)) {
return ['rows' => [], 'has_more' => false];
}
$columns = $this->columns();
foreach ([
'shop_name', 'online_order_no', 'internal_order_no', 'order_time',
'product_cost', 'refund_type', 'post_refund_amount', 'post_platform_subsidy',
'pre_refund_amount', 'pre_platform_subsidy', 'classification_order',
] as $field) {
if (! isset($columns[$field])) {
return ['rows' => [], 'has_more' => false];
}
}
$dateField = $this->dateField($filters);
if ($this->hasDateRange($filters)
&& $this->queryDateColumn($columns, $dateField) === null) {
return ['rows' => [], 'has_more' => false];
}
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$orderValues = self::batchValues((string) ($filters['order_no'] ?? ''));
$hasMore = null;
$selectedGroups = null;
if ($orderValues !== []) {
$ids = $this->orderNumberIds($orderValues, $columns);
if ($ids === []) {
return ['rows' => [], 'has_more' => false];
}
$query = $this->connection()->table(self::TABLE)->whereIn('_mirror_key', $ids);
$query = $this->filtered(
$query,
array_replace($filters, ['order_no' => '', 'from' => '', 'to' => '']),
$columns,
);
} else {
$filtered = $this->filtered($this->connection()->table(self::TABLE), $filters, $columns);
$groups = $this->pageOrderGroups($filtered, $columns, $dateField, $page, $limit);
if ($groups === []) {
return ['rows' => [], 'has_more' => false];
}
$hasMore = count($groups) > $limit;
$groups = array_slice($groups, 0, $limit);
$query = $this->filtered($this->connection()->table(self::TABLE), $filters, $columns);
$this->constrainToOrderGroups($query, $groups, $columns);
$selectedGroups = [];
foreach ($groups as $group) {
$selectedGroups[$group['shop_name'] . "\0" . $group['order_key']] = true;
}
}
$shop = $this->quote($columns['shop_name']);
$online = $this->quote($columns['online_order_no']);
$internal = $this->quote($columns['internal_order_no']);
$orderTime = $this->quote($columns['order_time']);
$refundType = $this->quote($columns['refund_type']);
$categoryOrder = $this->quote($columns['classification_order']);
$orderKey = "COALESCE(NULLIF(TRIM({$online}), ''), TRIM({$internal}))";
$sortDate = $this->quote(
$this->queryDateColumn($columns, $dateField) ?? $columns['order_time'],
);
$query
->selectRaw("{$shop} AS shop_name")
->selectRaw("{$online} AS online_order_no")
->selectRaw("MAX({$internal}) AS internal_order_no")
->selectRaw("{$orderKey} AS order_key")
->selectRaw("MAX({$orderTime}) AS order_time")
->selectRaw("MAX(NULLIF(TRIM({$refundType}), '')) AS refund_type")
->selectRaw("MAX(NULLIF(TRIM({$categoryOrder}), '')) AS classification_order")
->selectRaw('COUNT(*) AS source_row_count');
foreach (self::SUMMARY_NUMERIC_FIELDS as $alias => $fields) {
$query->selectRaw($this->sumExpression($columns, $fields) . ' AS ' . $alias);
}
$records = $query
->groupByRaw("{$shop}, {$orderKey}")
->orderByRaw("MAX({$sortDate}) DESC")
->orderByRaw("MAX({$internal}) DESC");
if ($selectedGroups === null) {
$records = $records->forPage($page, $limit + 1)->get();
} else {
$records = $records->get()->filter(static function (object $record) use ($selectedGroups): bool {
return isset($selectedGroups[
trim((string) ($record->shop_name ?? '')) . "\0"
. trim((string) ($record->order_key ?? ''))
]);
})->values();
}
$rows = $records->map(static function (object $record): array {
$row = [
'shop_name' => trim((string) ($record->shop_name ?? '')),
'online_order_no' => trim((string) ($record->online_order_no ?? '')),
'internal_order_no' => trim((string) ($record->internal_order_no ?? '')),
'order_key' => trim((string) ($record->order_key ?? '')),
'order_time' => $record->order_time,
'refund_type' => $record->refund_type,
'classification_order' => $record->classification_order,
'source_row_count' => (int) ($record->source_row_count ?? 0),
'matched_package_fee' => null,
'matched_freight_fee' => null,
'matched_bill_fee' => null,
'items' => [],
];
foreach (array_keys(self::SUMMARY_NUMERIC_FIELDS) as $field) {
$row[$field] = round((float) ($record->{$field} ?? 0), 2);
}
return $row;
})->all();
if ($hasMore === null) {
$hasMore = count($rows) > $limit;
}
$rows = array_slice($rows, 0, $limit);
$this->hydrateItems($rows, $columns);
return ['rows' => $rows, 'has_more' => $hasMore];
}
/** @return list<string> */
public function shopNames(): array
{
$cacheKey = 'order-profit.shop-options:undefined8';
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
if (! $this->ready()) {
return [];
}
$columns = $this->columns();
$shopColumn = $columns['shop_name'] ?? null;
if ($shopColumn === null) {
return [];
}
$names = $this->connection()->table(self::TABLE)
->select($shopColumn)
->whereNotNull($shopColumn)
->where($shopColumn, '<>', '')
->distinct()
->orderBy($shopColumn)
->pluck($shopColumn)
->map(static fn (mixed $name): string => trim((string) $name))
->filter()
->values()
->all();
Cache::put($cacheKey, $names, now()->addHours(24));
return $names;
}
/** @return array<string,string> */
private function columns(): array
{
return ($this->fields ?? app(GoodsProfitAfsRepository::class))->fieldColumns();
}
/**
* @param list<string> $orderValues
* @param array<string,string> $columns
* @return list<string>
*/
private function orderNumberIds(array $orderValues, array $columns): array
{
$ids = [];
$single = count($orderValues) === 1 ? $orderValues[0] : null;
foreach (['online_order_no', 'internal_order_no'] as $field) {
if (! isset($columns[$field])) {
continue;
}
if ($field === 'internal_order_no') {
$orderValues = array_values(array_filter(
$orderValues,
static fn (string $value): bool => mb_strlen($value) <= 11,
));
if ($orderValues === []) {
continue;
}
}
$query = $this->connection()->table(self::TABLE)->select('_mirror_key')->limit(10000);
if ($single !== null) {
$query->where($columns[$field], 'like', $single . '%');
} else {
$query->whereIn($columns[$field], $orderValues);
}
foreach ($query->pluck('_mirror_key') as $id) {
$ids[(string) $id] = true;
}
}
return array_keys($ids);
}
/**
* 先按日期/筛选条件只取当前页的订单键,再对少量订单做宽表汇总。
* 这样近 7 天未指定店铺时不会对整段宽表逐行汇总后再丢弃分页外数据。
*
* @param array<string,string> $columns
* @return list<array{shop_name:string,online_order_no:string,internal_order_no:string,order_key:string}>
*/
private function pageOrderGroups(
Builder $query,
array $columns,
string $dateField,
int $page,
int $pageSize,
): array {
$shop = $this->quote($columns['shop_name']);
$online = $this->quote($columns['online_order_no']);
$internal = $this->quote($columns['internal_order_no']);
$orderKey = "COALESCE(NULLIF(TRIM({$online}), ''), TRIM({$internal}))";
$sortDate = $this->quote(
$this->queryDateColumn($columns, $dateField) ?? $columns['order_time'],
);
return $query
->selectRaw("{$shop} AS shop_name")
->selectRaw("{$online} AS online_order_no")
->selectRaw("MAX({$internal}) AS internal_order_no")
->selectRaw("{$orderKey} AS order_key")
->groupByRaw("{$shop}, {$orderKey}")
->orderByRaw("MAX({$sortDate}) DESC")
->orderByRaw("MAX({$internal}) DESC")
->offset(($page - 1) * $pageSize)
->limit($pageSize + 1)
->get()
->map(static fn (object $row): array => [
'shop_name' => trim((string) ($row->shop_name ?? '')),
'online_order_no' => trim((string) ($row->online_order_no ?? '')),
'internal_order_no' => trim((string) ($row->internal_order_no ?? '')),
'order_key' => trim((string) ($row->order_key ?? '')),
])
->all();
}
/**
* @param list<array{shop_name:string,online_order_no:string,internal_order_no:string,order_key:string}> $groups
* @param array<string,string> $columns
*/
private function constrainToOrderGroups(Builder $query, array $groups, array $columns): void
{
$onlineOrders = [];
$internalOrders = [];
$shops = [];
foreach ($groups as $group) {
$shops[$group['shop_name']] = true;
if ($group['online_order_no'] !== '') {
$onlineOrders[$group['online_order_no']] = true;
} elseif ($group['internal_order_no'] !== '') {
$internalOrders[$group['internal_order_no']] = true;
}
}
$query->where(function (Builder $orders) use ($columns, $onlineOrders, $internalOrders): void {
if ($onlineOrders !== []) {
$orders->whereIn($columns['online_order_no'], array_keys($onlineOrders));
}
if ($internalOrders !== []) {
$method = $onlineOrders === [] ? 'whereIn' : 'orWhereIn';
$orders->{$method}($columns['internal_order_no'], array_keys($internalOrders));
}
});
$query->whereIn($columns['shop_name'], array_keys($shops));
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function filtered(Builder $query, array $filters, array $columns): Builder
{
$dateColumn = $this->queryDateColumn($columns, $this->dateField($filters));
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
if ($dateColumn !== null && $from !== '') {
$query->where($dateColumn, '>=', $from . ' 00:00:00');
}
if ($dateColumn !== null && $to !== '') {
$query->where($dateColumn, '<=', $to . ' 23:59:59');
}
$shops = self::batchValues((string) ($filters['shop_name'] ?? ''));
if ($shops !== [] && isset($columns['shop_name'])) {
$query->whereIn($columns['shop_name'], $shops);
}
$orderValues = self::batchValues((string) ($filters['order_no'] ?? ''));
if ($orderValues !== []) {
$query->where(function (Builder $order) use ($orderValues, $columns): void {
foreach (['online_order_no', 'internal_order_no'] as $field) {
if (! isset($columns[$field])) {
continue;
}
$order->orWhereIn($columns[$field], $orderValues);
}
});
}
$skuValues = self::batchValues((string) ($filters['sku_id'] ?? ''));
if ($skuValues !== [] && isset($columns['product_code'])) {
$query->whereIn($columns['product_code'], $skuValues);
}
return $query;
}
/**
* @param list<array<string,mixed>> $rows
* @param array<string,string> $columns
*/
private function hydrateItems(array &$rows, array $columns): void
{
if ($rows === []) {
return;
}
$itemFields = array_values(array_filter(
self::ITEM_FIELDS,
static fn (string $field): bool => isset($columns[$field]),
));
if ($itemFields === []) {
return;
}
$onlineValues = array_values(array_filter(array_column($rows, 'online_order_no')));
$internalValues = array_values(array_filter(array_column($rows, 'internal_order_no')));
if ($onlineValues === [] && $internalValues === []) {
return;
}
$query = $this->connection()->table(self::TABLE);
$query->where(function (Builder $orders) use ($columns, $onlineValues, $internalValues): void {
if ($onlineValues !== [] && isset($columns['online_order_no'])) {
$orders->whereIn($columns['online_order_no'], $onlineValues);
}
if ($internalValues !== [] && isset($columns['internal_order_no'])) {
$method = $onlineValues === [] ? 'whereIn' : 'orWhereIn';
$orders->{$method}($columns['internal_order_no'], $internalValues);
}
});
$select = [];
foreach ($itemFields as $field) {
$select[] = $columns[$field] . ' AS ' . $field;
}
$itemsByOrder = [];
foreach ($query->get($select) as $record) {
$item = [];
foreach ($itemFields as $field) {
$value = $record->{$field} ?? null;
$item[$field] = in_array($field, self::NUMERIC_FIELDS, true)
? ($value === null || $value === '' ? null : (float) $value)
: $value;
}
$online = trim((string) ($item['online_order_no'] ?? ''));
$internal = trim((string) ($item['internal_order_no'] ?? ''));
$orderKey = $online !== '' ? $online : $internal;
$itemsByOrder[$orderKey][] = $item;
}
foreach ($rows as &$row) {
$key = trim((string) ($row['order_key'] ?? ''));
$row['items'] = $itemsByOrder[$key] ?? [];
}
unset($row);
}
/** @param list<string> $fields */
private function sumExpression(array $columns, array $fields): string
{
$parts = [];
foreach ($fields as $field) {
if (isset($columns[$field])) {
$parts[] = 'COALESCE(CAST(NULLIF(TRIM(' . $this->quote($columns[$field])
. "), '') AS DECIMAL(18,2)), 0)";
}
}
return $parts === [] ? '0' : 'SUM(' . implode(' + ', $parts) . ')';
}
private function queryDateColumn(array $columns, string $dateField): ?string
{
$definition = GoodsProfitAfsLookupSchema::DATE_COLUMNS[$dateField] ?? null;
if ($definition === null) {
return null;
}
if (($columns[$dateField] ?? null) === $definition['source']
&& Schema::connection($this->connectionName())->hasColumn(self::TABLE, $definition['lookup'])) {
return $definition['lookup'];
}
return $columns[$dateField] ?? null;
}
private function connection(): ConnectionInterface
{
return DB::connection($this->connectionName());
}
private function connectionName(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
private function quote(string $column): string
{
if (preg_match('/^[A-Za-z0-9_]+$/', $column) !== 1) {
throw new \InvalidArgumentException('订单利润字段名不安全。');
}
return '`' . $column . '`';
}
/** @param array<string,mixed> $filters */
private function hasActiveFilters(array $filters): bool
{
foreach (['from', 'to', 'order_no', 'shop_name', 'sku_id'] as $key) {
if (trim((string) ($filters[$key] ?? '')) !== '') {
return true;
}
}
return false;
}
/** @param array<string,mixed> $filters */
private function dateField(array $filters): string
{
$dateField = trim((string) ($filters['date_field'] ?? 'pay_time'));
return isset(GoodsProfitAfsLookupSchema::DATE_COLUMNS[$dateField])
? $dateField
: 'pay_time';
}
/** @param array<string,mixed> $filters */
private function hasDateRange(array $filters): bool
{
return trim((string) ($filters['from'] ?? '')) !== ''
|| trim((string) ($filters['to'] ?? '')) !== '';
}
/** @return list<string> */
private static function batchValues(string $value): array
{
$parts = preg_split('/[\s,,;;]+/u', trim($value)) ?: [];
$values = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part !== '') {
$values[$part] = true;
}
if (count($values) >= 100) {
break;
}
}
return array_keys($values);
}
}