feat: 新增订单利润页面与明细查询 (#786)

新增订单利润页面、共享镜像查询、筛选聚合、明细展示、列设置与导出。

验证:20 项测试通过,249 个断言;真实页面代表订单 26092142570188 验收通过。
This commit is contained in:
renshen 2026-10-30 15:24:09 +08:00
parent 7b8a5e1214
commit 3bd2587c04
10 changed files with 1419 additions and 6 deletions

View File

@ -4706,6 +4706,7 @@ class Pages extends Controller
'return-pickup-management' => $this->contentForReturnPickupManagement(),
'withdrawal-management' => $this->contentForWithdrawalManagement(),
'systemization-todos' => app(\App\Http\Controllers\Business\SystemizationTodos::class)->page(),
'order-profit-list' => app(\Modules\EcommerceFinance\View\OrderProfitPageData::class)->page(),
'todos' => $this->contentForTodos(),
'after-sales-return-refund' => $this->contentForAfterSalesReturnRefund(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),

View File

@ -35,7 +35,7 @@ class ShowInAdmin
public static function groupBasicData(array $navigation): array
{
$keys = ['order-data', 'refunds'];
$keys = ['order-data', 'order-profit', 'refunds'];
$children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true)));
if ($children === []) {
return $navigation;

View File

@ -654,6 +654,18 @@ $navigation = [
['slug' => 'bill-offset', 'title' => '账单冲减'],
],
],
[
'slug' => 'order-profit',
'title' => '订单利润',
'icon' => 'trending_up',
'pages' => [
[
'slug' => 'order-profit-list',
'title' => '订单利润',
'show_in_section_menu' => false,
],
],
],
[
'slug' => 'refunds',
'title' => '退款数据',
@ -1116,6 +1128,7 @@ $requestedSectionOrder = [
'invoice-management',
'fund-management',
'order-data',
'order-profit',
'refunds',
'billing-transactions',
'planting-data',

View File

@ -0,0 +1,462 @@
<?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);
}
}

View File

@ -0,0 +1,159 @@
<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\OrderProfitRepository;
use Throwable;
class OrderProfitPageData
{
public function __construct(private readonly OrderProfitRepository $repository)
{
}
public function page(): array
{
return ['view' => 'business.pages.order-data.order-profit'] + $this->data();
}
public function data(?array $input = null, ?int $page = null, ?int $limit = null): array
{
$filters = $this->filters($input);
$page = max($page ?? (int) request('page', 1), 1);
$limit = min(max($limit ?? (int) setting('default.list_limit', 25), 1), 100);
$rows = $this->paginator([], $filters, $page, $limit);
$error = null;
$status = $this->repository->status();
$hasFilters = $this->hasFilters($filters);
if ($hasFilters && ($status['ready'] ?? false)) {
try {
$result = $this->repository->paginate($filters, $page, $limit);
$rows = $this->paginator(
(array) ($result['rows'] ?? []),
$filters,
$page,
$limit,
(bool) ($result['has_more'] ?? false),
);
} catch (Throwable $exception) {
report($exception);
$error = '订单利润数据暂时无法读取,请稍后重试。';
}
} elseif ($hasFilters && ! ($status['ready'] ?? false)) {
$error = '订单利润共享镜像尚未完成首次同步。';
}
return [
'orderProfitRows' => $rows,
'orderProfitFilters' => $filters,
'orderProfitShopOptions' => $this->repository->shopNames(),
'orderProfitStatus' => $status,
'orderProfitError' => $error,
'orderProfitHasFilters' => $hasFilters,
];
}
/** @return array{from:string,to:string,order_no:string,shop_name:string,shop_names:array<int,string>,sku_id:string} */
private function filters(?array $input): array
{
$input ??= request()->query();
$shops = [];
$rawShops = $input['shop_name'] ?? $input['shop_names'] ?? [];
foreach ((array) $rawShops as $shop) {
$shop = trim((string) $shop);
if ($shop !== '') {
$shops[$shop] = true;
}
if (count($shops) >= 50) {
break;
}
}
$orderNo = $this->batchText($input['order_no'] ?? null);
$sku = $this->batchText($input['sku_id'] ?? null);
$hasOtherFilters = $orderNo !== '' || $sku !== '' || $shops !== [];
$datesWereSubmitted = array_key_exists('from', $input) || array_key_exists('to', $input);
$reset = ! empty($input['reset']);
$from = $this->date($input['from'] ?? null);
$to = $this->date($input['to'] ?? null);
if (! $datesWereSubmitted && ! $reset && ! $hasOtherFilters) {
$from = now()->subDays(6)->toDateString();
$to = now()->toDateString();
}
return [
'from' => $from,
'to' => $to,
'order_no' => $orderNo,
'shop_name' => implode("\n", array_keys($shops)),
'shop_names' => array_keys($shops),
'sku_id' => $sku,
];
}
private function date(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$timestamp = strtotime($value);
return $timestamp === false ? '' : date('Y-m-d', $timestamp);
}
private function batchText(mixed $value): string
{
$values = is_array($value) ? $value : preg_split('/[\r\n,,;;]+/u', (string) $value);
$items = [];
foreach ((array) $values as $item) {
$item = trim((string) $item);
if ($item !== '') {
$items[$item] = true;
}
if (count($items) >= 100) {
break;
}
}
return implode("\n", array_keys($items));
}
private function hasFilters(array $filters): bool
{
return trim((string) $filters['order_no']) !== ''
|| trim((string) $filters['shop_name']) !== ''
|| trim((string) $filters['sku_id']) !== ''
|| $filters['from'] !== ''
|| $filters['to'] !== '';
}
private function paginator(
array $rows,
array $filters,
int $page,
int $limit,
?bool $hasMore = null,
): Paginator {
$paginator = new Paginator(
Collection::make($rows)->map(static fn (array $row): object => (object) $row),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
if ($hasMore !== null) {
$paginator->hasMorePagesWhen($hasMore);
}
return $paginator;
}
}

View File

@ -0,0 +1,439 @@
@php
$rows = $orderProfitRows ?? null;
$filters = $orderProfitFilters ?? [];
$status = $orderProfitStatus ?? [];
$hasFilters = (bool) ($orderProfitHasFilters ?? false);
$columns = [
'shop_name' => '店铺名称',
'online_order_no' => '线上单号',
'internal_order_no' => '内部单号',
'order_time' => '订单时间',
'product_quantity' => '数量',
'product_amount' => '商品金额',
'cost_amount' => '成本',
'actual_shipped_quantity' => '实发数量',
'actual_shipped_amount' => '实发金额',
'actual_shipped_cost' => '实发成本',
'merchant_discount' => '商家优惠',
'freight_income' => '运费收入',
'payment_amount' => '付款金额',
'platform_discount' => '其中:平台优惠',
'refund_amount' => '退款金额',
'refund_type' => '退货类型',
'platform_subsidy' => '退平台补贴',
'pre_platform_subsidy' => '发货前-退平台补贴',
'pre_application_quantity' => '发货前-申请数量',
'pre_application_amount' => '发货前-申请金额',
'pre_application_cost' => '发货前-申请成本',
'pre_return_quantity' => '发货前-退回数量',
'pre_return_amount' => '发货前-退回金额',
'pre_return_cost' => '发货前-退回成本',
'pre_refund_amount' => '发货前-退款金额',
'pre_refund_cost' => '发货前-退款成本',
'post_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' => '发货后-退款成本',
'defective_quantity' => '次品数量',
'matched_package_fee' => '包材费(自动匹配)',
'matched_freight_fee' => '快递费(自动匹配)',
'matched_bill_fee' => '账单费用(自动匹配)',
'classification_order' => '分类单',
'source_row_count' => '来源行数',
];
$numberFields = array_flip([
'product_quantity', 'product_amount', 'cost_amount',
'actual_shipped_quantity', 'actual_shipped_amount', 'actual_shipped_cost',
'merchant_discount', 'freight_income', 'payment_amount', 'platform_discount',
'refund_amount', 'platform_subsidy',
'pre_platform_subsidy', 'pre_application_quantity', 'pre_application_amount', 'pre_application_cost',
'pre_return_quantity', 'pre_return_amount', 'pre_return_cost', 'pre_refund_amount', 'pre_refund_cost',
'post_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',
'defective_quantity', 'matched_package_fee', 'matched_freight_fee',
'matched_bill_fee', 'source_row_count',
]);
$fixedColumns = [
'shop_name' => ['left' => '0rem', 'width' => '13rem'],
'online_order_no' => ['left' => '13rem', 'width' => '9rem'],
'internal_order_no' => ['left' => '22rem', 'width' => '6rem'],
'detail' => ['left' => '28rem', 'width' => '4.75rem'],
];
$fixedColumnStyle = static fn (string $field): string => sprintf(
'left: %1$s; width: %2$s; min-width: %2$s; max-width: %2$s;',
$fixedColumns[$field]['left'],
$fixedColumns[$field]['width'],
);
@endphp
<div class="upstream-data-page upstream-data-page--spaced">
@include('business.pages.partials.upstream-data-styles')
<style>
.order-profit-table { min-width: 290rem; }
.order-profit-table [data-order-profit-fixed-key="detail"] {
box-shadow: 2px 0 0 var(--vip-border, #d8d2c7);
}
.order-profit-note {
display: flex;
align-items: flex-start;
gap: .55rem;
padding: .75rem 1.1rem;
border-bottom: 1px solid var(--vip-border, #d8d2c7);
background: #fffdf8;
color: var(--vip-content-muted, #595959);
font-size: .78rem;
line-height: 1.5;
}
.order-profit-note .material-icons-outlined { color: var(--vip-content-accent-dark, #856632); font-size: 1rem; }
.order-profit-detail-button {
height: 1.8rem;
padding: 0 .55rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: .4rem;
background: #fff;
color: var(--vip-content-accent-dark, #856632);
cursor: pointer;
}
.order-profit-detail-button:hover { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-soft, #f7f0de); }
.order-profit-detail-row > td { padding: 0 !important; background: #fbfaf6 !important; }
.order-profit-detail-row[hidden] { display: none; }
.order-profit-detail-wrap { padding: .8rem 1rem 1rem 3rem; }
.order-profit-detail-title { margin-bottom: .55rem; color: var(--vip-content-accent-dark, #856632); font-size: .8rem; font-weight: 700; }
.order-profit-detail-table { width: 100%; min-width: 92rem; border-collapse: collapse; background: #fff; }
.order-profit-detail-table th,
.order-profit-detail-table td { padding: .42rem .55rem; border: 1px solid var(--vip-border, #d8d2c7); font-size: .72rem; white-space: nowrap; }
.order-profit-detail-table th { background: var(--vip-content-accent-soft, #f7f0de); text-align: left; }
.order-profit-detail-table .is-number { text-align: right; font-variant-numeric: tabular-nums; }
.order-profit-sync { padding: .6rem 1.1rem; border-bottom: 1px solid var(--vip-border, #d8d2c7); color: var(--vip-content-muted, #595959); font-size: .76rem; }
</style>
<form method="GET" action="{{ request()->url() }}" class="upstream-data-toolbar" aria-label="订单利润筛选">
<div class="upstream-data-field">
<label for="order-profit-from">订单开始日期</label>
<input id="order-profit-from" name="from" type="date" value="{{ $filters['from'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="order-profit-to">订单结束日期</label>
<input id="order-profit-to" name="to" type="date" value="{{ $filters['to'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="order-profit-order">订单号</label>
<textarea id="order-profit-order" name="order_no" rows="2"
placeholder="线上、内部或子订单号;可批量:每行一个">{{ $filters['order_no'] ?? '' }}</textarea>
</div>
@include('business.pages.partials.order-data-shop-picker', [
'shopPickerOptions' => $orderProfitShopOptions ?? [],
'shopPickerSelected' => $filters['shop_names'] ?? [],
'shopPickerToggleId' => 'order-profit-shop',
])
<div class="upstream-data-field">
<label for="order-profit-sku">商品编码</label>
<textarea id="order-profit-sku" name="sku_id" rows="2"
placeholder="商品编码;可批量:每行一个">{{ $filters['sku_id'] ?? '' }}</textarea>
</div>
<div class="upstream-data-actions">
<button type="submit" class="upstream-data-button upstream-data-button--primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a href="{{ route('business.pages.show', ['section' => 'order-profit', 'reset' => 1]) }}" class="upstream-data-button">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<button type="button" id="order-profit-export-button"
class="upstream-data-button"
data-export-table="order-profit-table"
data-export-filename="订单利润_第{{ $rows?->currentPage() ?? 1 }}页.csv"
@if (!$rows || $rows->isEmpty()) disabled="disabled" aria-disabled="true" @endif>
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>导出当前页</span>
</button>
</div>
</form>
<div class="order-profit-note">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<span>商品成本、退款、退货类型和退平台补贴来自聚水潭订单利润共享镜像;包材费、快递费、账单费用属于聚水潭页面自动匹配结果,当前源表未包含匹配结果,暂显示“—”。</span>
</div>
@if ($orderProfitError ?? null)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $orderProfitError }}</span>
</div>
@endif
<div class="upstream-data-table-panel">
@if (!empty($status))
<div class="order-profit-sync">
数据状态:{{ ($status['ready'] ?? false) ? '共享镜像已就绪' : '共享镜像同步中' }}
@if (!empty($status['last_succeeded_at'])) · 最近同步 {{ $status['last_succeeded_at'] }} @endif
</div>
@endif
<div class="upstream-data-table-scroll">
<table id="order-profit-table" class="upstream-data-table order-profit-table"
aria-label="订单利润" data-finance-table-name="订单利润"
data-order-profit-autofit-fixed>
<thead>
<tr>
@foreach ($columns as $field => $header)
<th data-field="{{ $field }}"
@if (isset($fixedColumns[$field]))
data-finance-fixed="true"
data-order-profit-fixed-key="{{ $field }}"
style="{{ $fixedColumnStyle($field) }}"
@endif
@class([
'is-number' => isset($numberFields[$field]),
'is-column-hidden' => $field === 'source_row_count',
])>{{ $header }}</th>
@if ($field === 'internal_order_no')
<th data-field="detail" data-finance-fixed="true"
data-order-profit-fixed-key="detail"
style="{{ $fixedColumnStyle('detail') }}">明细</th>
@endif
@endforeach
</tr>
</thead>
<tbody>
@forelse ($rows ?? [] as $row)
@php
$detailId = 'order-profit-detail-' . $loop->index;
$items = collect(data_get($row, 'items', []));
@endphp
<tr>
@foreach ($columns as $field => $header)
@php $value = data_get($row, $field); @endphp
<td data-export-value="{{ $value ?? '' }}"
data-export-number="{{ isset($numberFields[$field]) ? 'true' : 'false' }}"
data-export-field="{{ $field }}"
@if (isset($fixedColumns[$field]))
data-finance-fixed="true"
data-order-profit-fixed-key="{{ $field }}"
style="{{ $fixedColumnStyle($field) }}"
@endif
@class([
'is-number' => isset($numberFields[$field]),
'is-column-hidden' => $field === 'source_row_count',
])>
@if ($value === null || $value === '')
-
@elseif (isset($numberFields[$field]))
{{ number_format((float) $value, 2) }}
@else
{{ $value }}
@endif
</td>
@if ($field === 'internal_order_no')
<td data-export-field="detail" data-finance-fixed="true"
data-order-profit-fixed-key="detail"
style="{{ $fixedColumnStyle('detail') }}">
<button type="button" class="order-profit-detail-button"
data-order-profit-detail="{{ $detailId }}"
aria-expanded="false">查看</button>
</td>
@endif
@endforeach
</tr>
<tr id="{{ $detailId }}" class="order-profit-detail-row" hidden>
<td colspan="{{ count($columns) + 1 }}">
<div class="order-profit-detail-wrap">
<div class="order-profit-detail-title">
{{ data_get($row, 'shop_name') }} · {{ data_get($row, 'online_order_no') }} · 来源明细 {{ $items->count() }} 行
</div>
<div class="upstream-data-table-scroll">
<table class="order-profit-detail-table">
<thead>
<tr>
<th>售后单号</th><th>商品编码</th><th>款式编码</th>
<th>店铺商品编码</th><th>商品名称</th><th>颜色规格</th>
<th class="is-number">商品数量</th><th class="is-number">商品金额</th>
<th class="is-number">商品成本</th><th>退款类型</th>
<th class="is-number">发货后退款金额</th><th class="is-number">发货后退款成本</th>
<th class="is-number">退平台补贴</th><th>组合装</th><th>发货仓库</th>
<th>快递单号</th><th>快递公司</th><th>订单标签</th>
</tr>
</thead>
<tbody>
@forelse ($items as $item)
<tr>
<td>{{ data_get($item, 'after_sales_no') ?: '-' }}</td>
<td>{{ data_get($item, 'product_code') ?: '-' }}</td>
<td>{{ data_get($item, 'style_code') ?: '-' }}</td>
<td>{{ data_get($item, 'shop_product_code') ?: '-' }}</td>
<td>{{ data_get($item, 'product_name') ?: '-' }}</td>
<td>{{ data_get($item, 'color_spec') ?: '-' }}</td>
<td class="is-number">{{ number_format((float) data_get($item, 'product_quantity', 0), 2) }}</td>
<td class="is-number">{{ number_format((float) data_get($item, 'product_amount', 0), 2) }}</td>
<td class="is-number">{{ number_format((float) data_get($item, 'product_cost', 0), 2) }}</td>
<td>{{ data_get($item, 'refund_type') ?: '-' }}</td>
<td class="is-number">{{ number_format((float) data_get($item, 'post_refund_amount', 0), 2) }}</td>
<td class="is-number">{{ number_format((float) data_get($item, 'post_refund_cost', 0), 2) }}</td>
<td class="is-number">{{ number_format((float) data_get($item, 'post_platform_subsidy', 0), 2) }}</td>
<td>{{ data_get($item, 'combine_type') ?: '-' }}</td>
<td>{{ data_get($item, 'shipping_warehouse') ?: '-' }}</td>
<td>{{ data_get($item, 'express_no') ?: '-' }}</td>
<td>{{ data_get($item, 'express_company') ?: '-' }}</td>
<td>{{ data_get($item, 'order_tags') ?: '-' }}</td>
</tr>
@empty
<tr><td colspan="18">暂无来源明细</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="{{ count($columns) + 1 }}">
{{ $hasFilters ? '暂无订单利润数据' : '请输入订单号、店铺或日期后查询' }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="upstream-data-pagination">
<span>
当前页 {{ $rows?->count() ?? 0 }} 条
@if (!empty($status['ready'])) · 共享镜像 @endif
</span>
<div class="upstream-data-pagination__controls">
<a href="{{ $rows?->previousPageUrl() ?? '#' }}"
@class(['upstream-data-button', 'is-disabled' => !$rows || $rows->onFirstPage()])
aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<span>第 {{ $rows?->currentPage() ?? 1 }} 页</span>
<a href="{{ $rows?->nextPageUrl() ?? '#' }}"
@class(['upstream-data-button', 'is-disabled' => !$rows?->hasMorePages()])
aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
</div>
</div>
</div>
</div>
<script>
(() => {
const fitOrderProfitFixedColumns = () => {
const table = document.querySelector('table[data-order-profit-autofit-fixed]');
if (!table) return;
const cells = Array.from(table.querySelectorAll(
'thead th[data-order-profit-fixed-key], '
+ 'tbody > tr:not(.order-profit-detail-row):not(.is-empty) > td[data-order-profit-fixed-key]',
));
const widths = new Map();
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const minimumWidths = {
shop_name: rootFontSize * 8,
online_order_no: rootFontSize * 8,
internal_order_no: rootFontSize * 6,
detail: rootFontSize * 4.75,
};
cells.forEach((cell) => {
const key = cell.dataset.orderProfitFixedKey;
if (!key) return;
const style = getComputedStyle(cell);
const horizontalSpacing = [
style.paddingLeft,
style.paddingRight,
style.borderLeftWidth,
style.borderRightWidth,
].reduce((total, value) => total + (parseFloat(value) || 0), 0);
const detailButton = cell.querySelector('.order-profit-detail-button');
let contentWidth = 0;
if (detailButton) {
contentWidth = detailButton.getBoundingClientRect().width;
} else {
const range = document.createRange();
range.selectNodeContents(cell);
contentWidth = range.getBoundingClientRect().width;
}
const measuredWidth = Math.ceil(contentWidth + horizontalSpacing + 2);
widths.set(key, Math.max(widths.get(key) || 0, measuredWidth, minimumWidths[key] || 0));
});
let left = 0;
const orderedKeys = Array.from(table.tHead?.rows?.[0]?.cells || [])
.map((cell) => cell.dataset.orderProfitFixedKey)
.filter(Boolean);
orderedKeys.forEach((key) => {
const width = widths.get(key);
if (!width) return;
cells
.filter((cell) => cell.dataset.orderProfitFixedKey === key)
.forEach((cell) => {
cell.style.width = `${width}px`;
cell.style.minWidth = `${width}px`;
cell.style.maxWidth = `${width}px`;
cell.style.left = `${left}px`;
});
left += width;
});
};
const scheduleFixedColumnFit = () => {
window.requestAnimationFrame(() => window.requestAnimationFrame(fitOrderProfitFixedColumns));
};
scheduleFixedColumnFit();
document.fonts?.ready.then(scheduleFixedColumnFit).catch(() => {});
window.addEventListener('resize', scheduleFixedColumnFit, { passive: true });
document.querySelectorAll('[data-order-profit-detail]').forEach((button) => {
if (button.dataset.bound === 'true') return;
button.dataset.bound = 'true';
button.addEventListener('click', () => {
const row = document.getElementById(button.dataset.orderProfitDetail || '');
if (!row) return;
const open = row.hidden;
row.hidden = !open;
button.setAttribute('aria-expanded', open ? 'true' : 'false');
button.textContent = open ? '收起' : '查看';
});
});
const exportButton = document.getElementById('order-profit-export-button');
if (!exportButton || exportButton.dataset.bound === 'true') return;
exportButton.dataset.bound = 'true';
exportButton.addEventListener('click', () => {
const table = document.getElementById(exportButton.dataset.exportTable || '');
const bodyRows = table ? Array.from(table.querySelectorAll('tbody > tr:not(.order-profit-detail-row):not(.is-empty)')) : [];
if (!table || bodyRows.length === 0) return;
const protect = (value) => /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
const csvCell = (value, isNumber = false) => {
const raw = String(value ?? '').replace(/\r\n?/g, '\n');
const normalized = isNumber ? raw : protect(raw);
return `"${normalized.replace(/"/g, '""')}"`;
};
const headers = Array.from(table.querySelectorAll('thead th')).map((cell) => csvCell(cell.textContent.trim()));
const rows = bodyRows.map((row) => Array.from(row.querySelectorAll(':scope > td'))
.map((cell) => cell.matches('[data-export-value]')
? csvCell(cell.dataset.exportValue || '', cell.dataset.exportNumber === 'true')
: csvCell(cell.textContent.trim())));
const csv = [headers, ...rows].map((row) => row.join(',')).join('\r\n');
const url = URL.createObjectURL(new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = exportButton.dataset.exportFilename || '订单利润.csv';
document.body.appendChild(link);
link.click();
window.setTimeout(() => {
link.remove();
URL.revokeObjectURL(url);
}, 1000);
});
})();
</script>

View File

@ -0,0 +1,131 @@
<?php
namespace Tests\Feature\Business;
use Mockery;
use Modules\EcommerceFinance\Repositories\OrderProfitRepository;
use Tests\Feature\FeatureTestCase;
class OrderProfitPageTest extends FeatureTestCase
{
public function test_order_profit_page_loads_recent_rows_without_filters(): void
{
$repository = Mockery::mock(OrderProfitRepository::class);
$repository->shouldReceive('status')->once()->andReturn([
'ready' => true,
'initial_completed_at' => '2026-10-01 00:00:00',
'last_succeeded_at' => '2026-10-30 10:00:00',
'last_error_code' => null,
]);
$repository->shouldReceive('shopNames')->once()->andReturn([]);
$repository->shouldReceive('paginate')->once()
->with(
Mockery::on(static fn (array $filters): bool => $filters['from'] === now()->subDays(6)->toDateString()
&& $filters['to'] === now()->toDateString()),
1,
25,
)
->andReturn([
'has_more' => false,
'rows' => [[
'shop_name' => '最近订单店铺',
'online_order_no' => 'RECENT-ORDER-1',
'internal_order_no' => '9001',
'cost_amount' => 12.0,
'refund_amount' => 0.0,
'platform_subsidy' => 0.0,
'product_quantity' => 1.0,
'product_amount' => 20.0,
'items' => [],
]],
]);
$this->app->instance(OrderProfitRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'order-profit',
'page' => 'order-profit-list',
]))
->assertOk()
->assertSeeText('RECENT-ORDER-1')
->assertSeeText('最近订单店铺')
->assertSeeText('12.00');
}
public function test_order_profit_page_renders_aggregated_order_and_source_details(): void
{
$repository = Mockery::mock(OrderProfitRepository::class);
$repository->shouldReceive('status')->once()->andReturn([
'ready' => true,
'initial_completed_at' => '2026-10-01 00:00:00',
'last_succeeded_at' => '2026-10-30 10:00:00',
'last_error_code' => null,
]);
$repository->shouldReceive('shopNames')->once()->andReturn(['唯品会MP-卓禾家具特卖旗舰店']);
$repository->shouldReceive('paginate')->once()->andReturn([
'has_more' => false,
'rows' => [[
'shop_name' => '唯品会MP-卓禾家具特卖旗舰店',
'online_order_no' => '26092142570188',
'internal_order_no' => '9470588',
'order_time' => '2026/9/21 9:40:36',
'cost_amount' => 75.0,
'refund_amount' => 20.0,
'platform_subsidy' => 0.0,
'product_quantity' => 1.0,
'product_amount' => 259.0,
'refund_type' => '仅退款',
'classification_order' => '否',
'source_row_count' => 2,
'matched_package_fee' => null,
'matched_freight_fee' => null,
'matched_bill_fee' => null,
'items' => [[
'after_sales_no' => '743208893',
'product_code' => 'BJ018706N68V01',
'style_code' => 'BJ0187',
'shop_product_code' => 'SKU-1E73E04C8008B3AF',
'product_name' => '款式一 60/40/53 胡桃色',
'color_spec' => '胡桃色',
'product_quantity' => 0,
'product_amount' => 0,
'product_cost' => 0,
'refund_type' => '仅退款',
'post_refund_amount' => 20,
'post_refund_cost' => 0,
'post_platform_subsidy' => 0,
'combine_type' => '非组合',
'shipping_warehouse' => '简尚-崔桥工厂仓',
'express_no' => '76001654919378',
'express_company' => '新北中通',
'order_tags' => '发货后售后,店铺-唯品',
]],
]],
]);
$this->app->instance(OrderProfitRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'order-profit',
'page' => 'order-profit-list',
'order_no' => '26092142570188',
]))
->assertOk()
->assertSeeText('订单利润')
->assertSeeText('唯品会MP-卓禾家具特卖旗舰店')
->assertSeeText('26092142570188')
->assertSeeText('9470588')
->assertSeeText('75.00')
->assertSeeText('20.00')
->assertSeeText('仅退款')
->assertSeeText('包材费、快递费、账单费用属于聚水潭页面自动匹配结果')
->assertSeeText('743208893')
->assertSeeTextInOrder(['内部单号', '明细', '订单时间'])
->assertSee('data-finance-fixed="true"', false)
->assertSee('data-order-profit-autofit-fixed', false)
->assertSee('data-order-profit-fixed-key="detail"', false)
->assertSee('style="left: 28rem; width: 4.75rem; min-width: 4.75rem; max-width: 4.75rem;"', false)
->assertSee('data-order-profit-detail', false)
->assertSee('data-finance-table-name="订单利润"', false)
->assertSee('is-column-hidden', false)
->assertSee('/workspace/order-profit', false);
}
}

View File

@ -17,12 +17,25 @@ class NavigationTest extends FeatureTestCase
->assertSeeText('基础数据')
->assertSeeText('商品价格管理')
->assertSee('/workspace/order-data/raw-data', false)
->assertSee('/workspace/order-profit', false)
->assertSeeText('订单利润')
->assertSee('/workspace/refunds/non-jingxi-refund-data', false)
->assertSee('/workspace/product-inventory/normal-products', false)
->assertSee('/workspace/product-inventory/monthly-weighted-average-price', false)
->assertSee('/workspace/product-inventory/inventory-report', false);
}
public function testOrderProfitDirectoryOpensBeforeDataIsConnected(): void
{
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'order-profit',
'page' => 'order-profit-list',
]))
->assertOk()
->assertSeeText('订单利润');
}
public function testInventoryReportMenuPageIsAvailable(): void
{
$this->loginAs()
@ -386,6 +399,7 @@ class NavigationTest extends FeatureTestCase
$invoiceIndex = array_search('invoice-management', $slugs, true);
$fundIndex = array_search('fund-management', $slugs, true);
$orderIndex = array_search('order-data', $slugs, true);
$orderProfitIndex = array_search('order-profit', $slugs, true);
$refundIndex = array_search('refunds', $slugs, true);
$billingIndex = array_search('billing-transactions', $slugs, true);
$balanceIndex = array_search('account-balance-data', $slugs, true);
@ -399,6 +413,7 @@ class NavigationTest extends FeatureTestCase
$this->assertNotFalse($invoiceIndex);
$this->assertNotFalse($fundIndex);
$this->assertNotFalse($orderIndex);
$this->assertNotFalse($orderProfitIndex);
$this->assertNotFalse($refundIndex);
$this->assertNotFalse($billingIndex);
$this->assertNotFalse($balanceIndex);
@ -409,29 +424,32 @@ class NavigationTest extends FeatureTestCase
'invoice-management',
'fund-management',
'order-data',
'order-profit',
'refunds',
'billing-transactions',
'planting-data',
'account-balance-data',
'product-inventory',
],
array_slice($slugs, $invoiceIndex, 8)
array_slice($slugs, $invoiceIndex, 9)
);
$this->assertSame(
[
'票务管理',
'资金管理',
'订单数据',
'订单利润',
'退款数据',
'账单流水数据',
'种菜数据汇总',
'账单余额数据',
'商品价格管理',
],
array_column(array_slice($navigation, $invoiceIndex, 8), 'title')
array_column(array_slice($navigation, $invoiceIndex, 9), 'title')
);
$this->assertGreaterThan($invoiceIndex, $orderIndex);
$this->assertGreaterThan($orderIndex, $refundIndex);
$this->assertGreaterThan($orderIndex, $orderProfitIndex);
$this->assertGreaterThan($orderProfitIndex, $refundIndex);
$this->assertGreaterThan($refundIndex, $billingIndex);
$this->assertGreaterThan($billingIndex, $balanceIndex);
$this->assertGreaterThan($balanceIndex, $productIndex);

View File

@ -0,0 +1,189 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GoodsProfitAfsRepository;
use Modules\EcommerceFinance\Repositories\OrderProfitRepository;
use Modules\EcommerceFinance\Services\GoodsProfitAfsLookupSchema;
use Tests\TestCase;
class OrderProfitRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$schema = Schema::connection('business_mirror');
$schema->create(GoodsProfitAfsLookupSchema::TABLE, function (Blueprint $table): void {
$table->integer('id')->primary();
$table->string('_mirror_key')->unique();
foreach ([
'undefined1', 'undefined2', 'undefined3', 'undefined4', 'undefined7',
'undefined8', 'undefined10', 'undefined11', 'undefined12', 'undefined13',
'undefined18', 'undefined20', 'undefined21', 'undefined22', 'undefined43',
'undefined45', 'undefined46', 'undefined47', 'undefined48', 'undefined49',
'undefined50', 'undefined51', 'undefined52', 'undefined53', 'undefined54',
'undefined56', 'undefined59', 'undefined61', 'undefined66', 'undefined72',
'undefined73', 'undefined151',
] as $column) {
$table->string($column)->nullable();
}
});
$schema->create('ops_jst_goods_profit_kpi_items_afs_schemas', function (Blueprint $table): void {
$table->integer('id');
$table->string('column');
$table->string('name');
});
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key');
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->string('last_error_code')->nullable();
});
$db = DB::connection('business_mirror');
$db->table('business_mirror_table_states')->insert([
'dataset_key' => GoodsProfitAfsRepository::MAIN_DATASET,
'initial_completed_at' => '2026-10-01 00:00:00',
'last_succeeded_at' => '2026-10-30 10:00:00',
]);
$headers = [
'undefined1' => '内部订单号',
'undefined2' => '线上订单号',
'undefined3' => '子订单号',
'undefined4' => '售后单号',
'undefined7' => '订单时间',
'undefined8' => '店铺名称',
'undefined10' => '商品编码',
'undefined11' => '款式编码',
'undefined12' => '店铺商品编码',
'undefined13' => '店铺款式编码',
'undefined18' => '颜色及规格',
'undefined20' => '商品数量',
'undefined21' => '商品金额',
'undefined22' => '商品成本',
'undefined43' => '发货前退款金额',
'undefined45' => '发货前退平台补贴',
'undefined46' => '发货后申请数量',
'undefined47' => '发货后申请金额',
'undefined48' => '发货后申请成本',
'undefined49' => '发货后退回数量',
'undefined50' => '发货后退回金额',
'undefined51' => '发货后退回成本',
'undefined52' => '发货后退款金额',
'undefined53' => '发货后退款成本',
'undefined54' => '发货后退平台补贴',
'undefined56' => '退款类型',
'undefined59' => '分类单',
'undefined61' => '组合装',
'undefined66' => '发货仓库',
'undefined72' => '快递单号',
'undefined73' => '快递公司',
'undefined151' => '商品名称',
];
foreach ($headers as $index => $name) {
$db->table('ops_jst_goods_profit_kpi_items_afs_schemas')->insert([
'id' => (int) substr($index, 9),
'column' => $index,
'name' => $name,
]);
}
$base = [
'undefined2' => '26092142570188',
'undefined3' => '26092142570188_SKU-1E73E04C8008B3AF',
'undefined7' => '2026/9/21 9:40:36',
'undefined8' => '唯品会MP-卓禾家具特卖旗舰店',
'undefined10' => 'BJ018706N68V01',
'undefined11' => 'BJ0187',
'undefined12' => 'SKU-1E73E04C8008B3AF',
'undefined13' => 'SPU-1E73E04C800003AF',
'undefined18' => '胡桃色',
'undefined59' => '否',
'undefined61' => '非组合',
'undefined66' => '简尚-崔桥工厂仓',
'undefined72' => '76001654919378',
'undefined73' => '新北中通',
'undefined151' => '款式一 60/40/53 胡桃色',
];
$db->table(GoodsProfitAfsLookupSchema::TABLE)->insert(array_replace($base, [
'id' => 1,
'_mirror_key' => 'row-1',
'undefined1' => '9470588',
'undefined4' => '',
'undefined20' => '1',
'undefined21' => '259',
'undefined22' => '75',
'undefined43' => '0',
'undefined45' => '0',
'undefined46' => '0',
'undefined47' => '0',
'undefined48' => '0',
'undefined49' => '0',
'undefined50' => '0',
'undefined51' => '0',
'undefined52' => '0',
'undefined53' => '0',
'undefined54' => '0',
'undefined56' => '',
]));
$db->table(GoodsProfitAfsLookupSchema::TABLE)->insert(array_replace($base, [
'id' => 2,
'_mirror_key' => 'row-2',
'undefined1' => '9470588',
'undefined4' => '743208893',
'undefined20' => '0',
'undefined21' => '0',
'undefined22' => '0',
'undefined43' => '0',
'undefined45' => '0',
'undefined46' => '0',
'undefined47' => '178.3',
'undefined48' => '0',
'undefined49' => '0',
'undefined50' => '0',
'undefined51' => '0',
'undefined52' => '20',
'undefined53' => '0',
'undefined54' => '0',
'undefined56' => '仅退款',
]));
}
public function test_order_rows_are_aggregated_from_item_and_refund_lines(): void
{
$repository = app(OrderProfitRepository::class);
$result = $repository->paginate([
'from' => '2026-10-01',
'to' => '2026-10-31',
'order_no' => '26092142570188',
'shop_name' => '',
'sku_id' => '',
], 1, 25);
$this->assertCount(1, $result['rows']);
$row = $result['rows'][0];
$this->assertSame('唯品会MP-卓禾家具特卖旗舰店', $row['shop_name']);
$this->assertSame('26092142570188', $row['online_order_no']);
$this->assertSame('9470588', $row['internal_order_no']);
$this->assertEquals(75.0, $row['cost_amount']);
$this->assertEquals(20.0, $row['refund_amount']);
$this->assertEquals(178.3, $row['post_application_amount']);
$this->assertEquals(0.0, $row['post_return_amount']);
$this->assertSame('仅退款', $row['refund_type']);
$this->assertCount(2, $row['items']);
$this->assertSame(['唯品会MP-卓禾家具特卖旗舰店'], $repository->shopNames());
}
}

View File

@ -9,14 +9,15 @@ require_once dirname(__DIR__, 3) . '/app/Listeners/Menu/ShowInAdmin.php';
class BasicDataNavigationTest extends TestCase
{
public function test_group_moves_only_order_data_and_refunds_without_changing_pages(): void
public function test_group_moves_order_data_order_profit_and_refunds_without_changing_pages(): void
{
$navigation = require dirname(__DIR__, 3) . '/config/business_navigation.php';
$grouped = ShowInAdmin::groupBasicData($navigation);
$slugs = array_column($grouped, 'slug');
$group = $grouped[array_search('basic-data', $slugs, true)];
$this->assertSame(['order-data', 'refunds'], array_column($group['sections'], 'slug'));
$this->assertSame(['order-data', 'order-profit', 'refunds'], array_column($group['sections'], 'slug'));
$this->assertNotContains('order-data', $slugs);
$this->assertNotContains('order-profit', $slugs);
$this->assertNotContains('refunds', $slugs);
foreach ($group['sections'] as $section) {
$this->assertSame($navigation[array_search($section['slug'], array_column($navigation, 'slug'), true)], $section);