feat: 新增订单利润页面与明细查询 (#786)
新增订单利润页面、共享镜像查询、筛选聚合、明细展示、列设置与导出。 验证:20 项测试通过,249 个断言;真实页面代表订单 26092142570188 验收通过。
This commit is contained in:
parent
7b8a5e1214
commit
3bd2587c04
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
462
modules/EcommerceFinance/Repositories/OrderProfitRepository.php
Normal file
462
modules/EcommerceFinance/Repositories/OrderProfitRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
159
modules/EcommerceFinance/View/OrderProfitPageData.php
Normal file
159
modules/EcommerceFinance/View/OrderProfitPageData.php
Normal 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;
|
||||
}
|
||||
}
|
||||
439
resources/views/business/pages/order-data/order-profit.blade.php
Normal file
439
resources/views/business/pages/order-data/order-profit.blade.php
Normal 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>
|
||||
131
tests/Feature/Business/OrderProfitPageTest.php
Normal file
131
tests/Feature/Business/OrderProfitPageTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
189
tests/Unit/EcommerceFinance/OrderProfitRepositoryTest.php
Normal file
189
tests/Unit/EcommerceFinance/OrderProfitRepositoryTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user