diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index bd0bc80f..604ec2a1 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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(), diff --git a/app/Listeners/Menu/ShowInAdmin.php b/app/Listeners/Menu/ShowInAdmin.php index b1194183..051e3d04 100644 --- a/app/Listeners/Menu/ShowInAdmin.php +++ b/app/Listeners/Menu/ShowInAdmin.php @@ -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; diff --git a/config/business_navigation.php b/config/business_navigation.php index 5782f182..6f42789d 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -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', diff --git a/modules/EcommerceFinance/Repositories/OrderProfitRepository.php b/modules/EcommerceFinance/Repositories/OrderProfitRepository.php new file mode 100644 index 00000000..943f8e02 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/OrderProfitRepository.php @@ -0,0 +1,462 @@ + ['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 $filters + * @return array{rows:list>,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 */ + 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 */ + private function columns(): array + { + return ($this->fields ?? app(GoodsProfitAfsRepository::class))->fieldColumns(); + } + + /** + * @param list $orderValues + * @param array $columns + * @return list + */ + 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 $filters + * @param array $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> $rows + * @param array $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 $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 $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 */ + 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); + } +} diff --git a/modules/EcommerceFinance/View/OrderProfitPageData.php b/modules/EcommerceFinance/View/OrderProfitPageData.php new file mode 100644 index 00000000..0e2e212b --- /dev/null +++ b/modules/EcommerceFinance/View/OrderProfitPageData.php @@ -0,0 +1,159 @@ + '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,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; + } +} diff --git a/resources/views/business/pages/order-data/order-profit.blade.php b/resources/views/business/pages/order-data/order-profit.blade.php new file mode 100644 index 00000000..2ea79f0a --- /dev/null +++ b/resources/views/business/pages/order-data/order-profit.blade.php @@ -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 + +
+ @include('business.pages.partials.upstream-data-styles') + + + +
+
+ + +
+
+ + +
+
+ + +
+ @include('business.pages.partials.order-data-shop-picker', [ + 'shopPickerOptions' => $orderProfitShopOptions ?? [], + 'shopPickerSelected' => $filters['shop_names'] ?? [], + 'shopPickerToggleId' => 'order-profit-shop', + ]) +
+ + +
+
+ + + + 重置 + + +
+
+ +
+ + 商品成本、退款、退货类型和退平台补贴来自聚水潭订单利润共享镜像;包材费、快递费、账单费用属于聚水潭页面自动匹配结果,当前源表未包含匹配结果,暂显示“—”。 +
+ + @if ($orderProfitError ?? null) + + @endif + +
+ @if (!empty($status)) +
+ 数据状态:{{ ($status['ready'] ?? false) ? '共享镜像已就绪' : '共享镜像同步中' }} + @if (!empty($status['last_succeeded_at'])) · 最近同步 {{ $status['last_succeeded_at'] }} @endif +
+ @endif +
+ + + + @foreach ($columns as $field => $header) + + @if ($field === 'internal_order_no') + + @endif + @endforeach + + + + @forelse ($rows ?? [] as $row) + @php + $detailId = 'order-profit-detail-' . $loop->index; + $items = collect(data_get($row, 'items', [])); + @endphp + + @foreach ($columns as $field => $header) + @php $value = data_get($row, $field); @endphp + + @if ($field === 'internal_order_no') + + @endif + @endforeach + + + + + @empty + + + + @endforelse + +
isset($numberFields[$field]), + 'is-column-hidden' => $field === 'source_row_count', + ])>{{ $header }}明细
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 + + +
+ {{ $hasFilters ? '暂无订单利润数据' : '请输入订单号、店铺或日期后查询' }} +
+
+
+ + 当前页 {{ $rows?->count() ?? 0 }} 条 + @if (!empty($status['ready'])) · 共享镜像 @endif + + +
+
+
+ + diff --git a/tests/Feature/Business/OrderProfitPageTest.php b/tests/Feature/Business/OrderProfitPageTest.php new file mode 100644 index 00000000..1f312016 --- /dev/null +++ b/tests/Feature/Business/OrderProfitPageTest.php @@ -0,0 +1,131 @@ +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); + } +} diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 1dcaae45..9d696eaa 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -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); diff --git a/tests/Unit/EcommerceFinance/OrderProfitRepositoryTest.php b/tests/Unit/EcommerceFinance/OrderProfitRepositoryTest.php new file mode 100644 index 00000000..78b86e6b --- /dev/null +++ b/tests/Unit/EcommerceFinance/OrderProfitRepositoryTest.php @@ -0,0 +1,189 @@ + [ + '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()); + } +} diff --git a/tests/Unit/View/BasicDataNavigationTest.php b/tests/Unit/View/BasicDataNavigationTest.php index 162b25db..8bd2a8b9 100644 --- a/tests/Unit/View/BasicDataNavigationTest.php +++ b/tests/Unit/View/BasicDataNavigationTest.php @@ -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);