CaiWuXiTong/modules/EcommerceFinance/Repositories/OrderProfitRepository.php
renshen 3bd2587c04 feat: 新增订单利润页面与明细查询 (#786)
新增订单利润页面、共享镜像查询、筛选聚合、明细展示、列设置与导出。

验证:20 项测试通过,249 个断言;真实页面代表订单 26092142570188 验收通过。
2026-10-30 15:24:09 +08:00

463 lines
18 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',
'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];
}
}
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$orderValues = self::batchValues((string) ($filters['order_no'] ?? ''));
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 {
$query = $this->filtered($this->connection()->table(self::TABLE), $filters, $columns);
}
$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}))";
$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({$orderTime}) DESC")
->orderByDesc($columns['internal_order_no'])
->forPage($page, $limit + 1)
->get();
$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();
$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);
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function filtered(Builder $query, array $filters, array $columns): Builder
{
$dateColumn = $this->queryDateColumn($columns);
$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
{
return Schema::connection($this->connectionName())->hasColumn(
self::TABLE,
GoodsProfitAfsLookupSchema::DATE_COLUMN,
)
? GoodsProfitAfsLookupSchema::DATE_COLUMN
: ($columns['order_time'] ?? 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;
}
/** @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);
}
}