新增订单利润页面、共享镜像查询、筛选聚合、明细展示、列设置与导出。 验证:20 项测试通过,249 个断言;真实页面代表订单 26092142570188 验收通过。
463 lines
18 KiB
PHP
463 lines
18 KiB
PHP
<?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);
|
||
}
|
||
}
|