diff --git a/app/Business/JstOrderLabelImporter.php b/app/Business/JstOrderLabelImporter.php new file mode 100644 index 00000000..7d5471a8 --- /dev/null +++ b/app/Business/JstOrderLabelImporter.php @@ -0,0 +1,154 @@ +normalizeDate($from, '起始日期'); + $to = $this->normalizeDate($to, '结束日期'); + if ($from > $to) { + throw new InvalidArgumentException('起始日期不能晚于结束日期。'); + } + + $start = $from . ' 00:00:00'; + $end = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00'; + + $mirror = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror')); + if (! $mirror->getSchemaBuilder()->hasTable('ops_jst_orders')) { + throw new RuntimeException('聚水潭订单镜像尚未建立(ops_jst_orders)。'); + } + + $target = $this->databases->connection(); + $startedAt = microtime(true); + + if ($replace) { + $target->table('jst_order_labels') + ->where('company_id', $companyId) + ->where('ordered_at', '>=', $start) + ->where('ordered_at', '<', $end) + ->delete(); + } + + $now = now(); + $orders = 0; + $rows = 0; + $noLabelOrders = 0; + $buffer = []; + + $flush = function () use (&$buffer, &$rows, $target): void { + foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) { + $target->table('jst_order_labels')->insert($chunk); + $rows += count($chunk); + } + $buffer = []; + }; + + $mirror->table('ops_jst_orders') + ->where('order_date', '>=', $start) + ->where('order_date', '<', $end) + ->orderBy('Id') + ->chunkById(self::READ_CHUNK, function ($records) use (&$buffer, &$orders, &$noLabelOrders, $flush, $companyId, $source, $now): void { + foreach ($records as $record) { + $orders++; + $orderedAt = $record->order_date === null ? null : substr((string) $record->order_date, 0, 19); + if ($orderedAt === null || $orderedAt === '') { + continue; + } + + $labels = $this->splitLabels((string) ($record->labels ?? '')); + if ($labels === []) { + $labels = [self::NO_LABEL]; + $noLabelOrders++; + } + + foreach ($labels as $label) { + $buffer[] = [ + 'company_id' => $companyId, + 'order_no' => mb_substr((string) $record->so_id, 0, 100), + 'shop_name' => mb_substr((string) ($record->shop_name ?? ''), 0, 191), + 'ordered_at' => $orderedAt, + 'label' => $label, + 'created_from' => $source, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (count($buffer) >= self::INSERT_CHUNK * 2) { + $flush(); + } + } + }, 'Id'); + + $flush(); + + $labels = $target->table('jst_order_labels') + ->where('company_id', $companyId) + ->where('ordered_at', '>=', $start) + ->where('ordered_at', '<', $end) + ->distinct() + ->count('label'); + + return [ + 'from' => $from, + 'to' => $to, + 'orders' => $orders, + 'rows' => $rows, + 'no_label_orders' => $noLabelOrders, + 'labels' => (int) $labels, + 'seconds' => round(microtime(true) - $startedAt, 1), + ]; + } + + /** @return list */ + private function splitLabels(string $labels): array + { + $out = []; + foreach (explode(',', $labels) as $label) { + $label = trim($label); + if ($label !== '') { + $out[] = mb_substr($label, 0, 191); + } + } + + return array_values(array_unique($out)); + } + + private function normalizeDate(string $value, string $label): string + { + $value = trim($value); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。'); + } + + [$year, $month, $day] = array_map('intval', explode('-', $value)); + if (! checkdate($month, $day, $year)) { + throw new InvalidArgumentException($label . '不是有效日期。'); + } + + return $value; + } +} diff --git a/app/Console/Commands/ImportJstOrderLabels.php b/app/Console/Commands/ImportJstOrderLabels.php new file mode 100644 index 00000000..6e71a18d --- /dev/null +++ b/app/Console/Commands/ImportJstOrderLabels.php @@ -0,0 +1,42 @@ +option('from'); + $to = (string) $this->option('to'); + + try { + $result = $importer->import( + $from, + $to, + (int) $this->option('company'), + (bool) $this->option('replace'), + 'jst-order-label-import', + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->info(json_encode($result, JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index a423392f..c8e7866d 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1254,10 +1254,121 @@ class Pages extends Controller 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), 'account-mapping' => $this->contentForAccountMapping(), 'planting-details' => $this->contentForPlantingDetails(), + 'jst-order-labels' => $this->contentForJstOrderLabels(), default => null, }; } + /** + * 账单流水数据 › 聚水潭订单标签明细。 + * + * 按标签拆行展示(一个标签一行),不做标签汇总、不对订单去重。 + */ + protected function contentForJstOrderLabels(): array + { + $request = request(); + $filters = $this->jstOrderLabelFilters($request); + $query = $this->jstOrderLabelQuery($filters); + + $limit = min(max((int) setting('default.list_limit', 25), 10), 100); + $records = $query + ->orderByDesc('ordered_at') + ->orderByDesc('id') + ->paginate($limit) + ->withQueryString(); + + return [ + 'view' => 'business.pages.billing-transactions.jst-order-labels', + 'records' => $records, + 'filters' => $filters, + 'totalRows' => $records->total(), + 'filterOptions' => [ + 'shops' => $this->jstOrderLabelOptions('shop_name', 500), + 'labels' => $this->jstOrderLabelOptions('label', 500), + ], + ]; + } + + /** @return array */ + private function jstOrderLabelFilters(Request $request): array + { + return [ + 'date_from' => $this->dateFilter($request->query('date_from')), + 'date_to' => $this->dateFilter($request->query('date_to')), + 'shop_name' => mb_substr(trim((string) $request->query('shop_name')), 0, 191), + 'label' => mb_substr(trim((string) $request->query('label')), 0, 191), + 'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100), + ]; + } + + /** @param array $filters */ + private function jstOrderLabelQuery(array $filters): \Illuminate\Database\Eloquent\Builder + { + $query = \App\Models\Business\JstOrderLabel::query()->where('company_id', company_id()); + + if ($filters['date_from'] !== '') { + $query->where('ordered_at', '>=', $filters['date_from'] . ' 00:00:00'); + } + if ($filters['date_to'] !== '') { + $query->where('ordered_at', '<', date('Y-m-d', strtotime($filters['date_to'] . ' +1 day')) . ' 00:00:00'); + } + if ($filters['shop_name'] !== '') { + $query->where('shop_name', $filters['shop_name']); + } + if ($filters['label'] !== '') { + $query->where('label', 'like', '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['label']) . '%'); + } + if ($filters['keyword'] !== '') { + $query->where('order_no', 'like', '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['keyword']) . '%'); + } + + return $query; + } + + /** @return list */ + private function jstOrderLabelOptions(string $column, int $limit): array + { + return \App\Models\Business\JstOrderLabel::query() + ->where('company_id', company_id()) + ->whereNotNull($column) + ->where($column, '<>', '') + ->distinct() + ->orderBy($column) + ->limit($limit) + ->pluck($column) + ->all(); + } + + /** 账单流水数据 › 聚水潭订单标签明细导出(按当前筛选,全量) */ + public function exportJstOrderLabels(Request $request): StreamedResponse + { + $query = $this->jstOrderLabelQuery($this->jstOrderLabelFilters($request)); + + return response()->streamDownload(function () use ($query): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new \RuntimeException('无法创建聚水潭订单标签明细导出流。'); + } + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, ['线上订单号', '店铺名称', '下单时间', '标签']); + + $query->orderBy('id')->chunkById(2000, function ($rows) use ($csv): void { + foreach ($rows as $row) { + fputcsv($csv, [ + $row->order_no, + $row->shop_name, + $row->ordered_at?->format('Y-m-d H:i:s'), + $row->label, + ]); + } + }); + fclose($csv); + }, '聚水潭订单标签明细_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + /** * 账单流水数据 › 种菜明细表。 */ diff --git a/app/Models/Business/JstOrderLabel.php b/app/Models/Business/JstOrderLabel.php new file mode 100644 index 00000000..b86cd338 --- /dev/null +++ b/app/Models/Business/JstOrderLabel.php @@ -0,0 +1,26 @@ + 'datetime', + ]; +} diff --git a/config/business_navigation.php b/config/business_navigation.php index f7de56b7..3015693c 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -427,6 +427,7 @@ $navigation = [ 'pages' => [ ['slug' => 'bank-transactions', 'title' => '银行账户流水'], ['slug' => 'planting-details', 'title' => '种菜明细表'], + ['slug' => 'jst-order-labels', 'title' => '聚水潭订单标签明细'], [ 'slug' => 'store-receiving-bills', 'title' => '店铺收款账户账单', diff --git a/database/migrations/2026_09_30_160000_create_jst_order_labels_table.php b/database/migrations/2026_09_30_160000_create_jst_order_labels_table.php new file mode 100644 index 00000000..e963d8d3 --- /dev/null +++ b/database/migrations/2026_09_30_160000_create_jst_order_labels_table.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->string('order_no', 100)->comment('线上订单号'); + $table->string('shop_name', 191)->nullable()->comment('店铺名称'); + $table->dateTime('ordered_at')->comment('下单时间'); + $table->string('label', 191)->comment('标签(单个)'); + $table->string('created_from', 100)->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'ordered_at'], 'jst_order_labels_company_time'); + $table->index(['company_id', 'label'], 'jst_order_labels_company_label'); + $table->index(['company_id', 'shop_name'], 'jst_order_labels_company_shop'); + $table->index('order_no', 'jst_order_labels_order_no'); + }); + } + + public function down(): void + { + Schema::dropIfExists('jst_order_labels'); + } +}; diff --git a/resources/views/business/pages/billing-transactions/jst-order-labels.blade.php b/resources/views/business/pages/billing-transactions/jst-order-labels.blade.php new file mode 100644 index 00000000..0e980b26 --- /dev/null +++ b/resources/views/business/pages/billing-transactions/jst-order-labels.blade.php @@ -0,0 +1,213 @@ +@php + $records = $records ?? null; + $filters = $filters ?? []; + $totalRows = (int) ($totalRows ?? ($records?->total() ?? 0)); + $filterOptions = $filterOptions ?? ['shops' => [], 'labels' => []]; +@endphp + +
+ @include('business.pages.partials.upstream-data-styles') + + + +

+ 数据来源:聚水潭订单(共享镜像 ops_jst_orders),标签按行拆开、订单不去重、标签不汇总;无标签订单记为「(无标签)」。 + 当前筛选共 {{ number_format($totalRows) }} 行。 +

+ +
+
+ + + + + +
+ +
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + + + + + + + + + @forelse ($records as $index => $record) + + + + + + + + @empty + + + + @endforelse + +
序号线上订单号店铺名称下单时间标签
{{ $records->firstItem() + $index }}{{ $record->order_no ?: '-' }}{{ $record->shop_name ?: '-' }}{{ $record->ordered_at?->format('Y-m-d H:i:s') ?? '-' }}{{ $record->label ?: '-' }}
+ {{ $totalRows === 0 ? '暂无数据,请先用 jst-order-labels:import 导入订单标签明细。' : '当前筛选条件下没有记录,请调整筛选条件后重试。' }} +
+
+ +
+ + 共 {{ number_format($records->total()) }} 行 + · 每页 {{ $records->perPage() }} 行 + · 第 {{ $records->currentPage() }} / {{ max($records->lastPage(), 1) }} 页 + +
+ @if ($records->onFirstPage()) + + + + @else + + + + @endif + 第 {{ $records->currentPage() }} / {{ max($records->lastPage(), 1) }} 页 + @if ($records->hasMorePages()) + + + + @else + + + + @endif +
+
+
+
+ + diff --git a/routes/admin.php b/routes/admin.php index 147c7ee9..ee40843a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -172,6 +172,10 @@ Route::get('workspace/billing-transactions/planting-details/export', 'Business\P ->middleware('business.page:billing-transactions,planting-details') ->name('business.billing-transactions.planting-details.export'); +Route::get('workspace/billing-transactions/jst-order-labels/export', 'Business\Pages@exportJstOrderLabels') + ->middleware('business.page:billing-transactions,jst-order-labels') + ->name('business.billing-transactions.jst-order-labels.export'); + Route::prefix('workspace/product-inventory/inventory-report/warehouse-mappings') ->middleware('business.page:product-inventory,inventory-report') ->name('business.product-inventory.inventory-report.warehouse-mappings.') diff --git a/tests/Feature/Business/JstOrderLabelsPageTest.php b/tests/Feature/Business/JstOrderLabelsPageTest.php new file mode 100644 index 00000000..b7cbe00e --- /dev/null +++ b/tests/Feature/Business/JstOrderLabelsPageTest.php @@ -0,0 +1,116 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]]); + DB::purge('business_mirror'); + + Schema::connection('business_mirror')->create('ops_jst_orders', function ($table): void { + $table->integer('Id')->primary(); + $table->string('so_id')->nullable(); + $table->string('shop_name')->nullable(); + $table->dateTime('order_date')->nullable(); + $table->text('labels')->nullable(); + }); + + DB::connection('business_mirror')->table('ops_jst_orders')->insert([ + ['Id' => 1, 'so_id' => '260901-391328633273048', 'shop_name' => '拼多多-优乐猫家具旗舰店', 'order_date' => '2026-09-01 00:00:13', 'labels' => '发货前售后,店铺-拼多多'], + ['Id' => 2, 'so_id' => '3608400015164657', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'order_date' => '2026-09-01 00:00:44', 'labels' => '京东供销订单, 京喜'], + ['Id' => 3, 'so_id' => '110211297173802010', 'shop_name' => '得物-全米', 'order_date' => '2026-08-31 23:59:00', 'labels' => '品牌直发'], + ['Id' => 4, 'so_id' => '260915-000000000000001', 'shop_name' => '得物-全米', 'order_date' => '2026-09-15 10:00:00', 'labels' => ''], + ]); + } + + public function test_import_splits_labels_without_deduping_orders(): void + { + $this->allowPage(); + + $result = app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true); + + $this->assertSame(3, $result['orders']); + $this->assertSame(5, $result['rows']); + $this->assertSame(1, $result['no_label_orders']); + + $rows = JstOrderLabel::query()->where('company_id', $this->company->id)->orderBy('id')->get(); + $this->assertSame(['发货前售后', '店铺-拼多多', '京东供销订单', '京喜', '(无标签)'], $rows->pluck('label')->all()); + // 同一订单拆成多行,订单不去重 + $this->assertSame(2, $rows->where('order_no', '260901-391328633273048')->count()); + $this->assertSame('2026-09-15 10:00:00', $rows->last()->ordered_at->format('Y-m-d H:i:s')); + } + + public function test_import_window_excludes_out_of_range_orders(): void + { + $this->allowPage(); + + // 8-31 的订单不在 9 月窗口内 + $result = app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true); + $orders = JstOrderLabel::query()->where('company_id', $this->company->id)->pluck('order_no')->unique()->all(); + + $this->assertNotContains('110211297173802010', $orders); + $this->assertSame(3, $result['orders']); + } + + public function test_page_filters_and_export(): void + { + $this->allowPage(); + app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true); + + $page = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'billing-transactions', + 'page' => 'jst-order-labels', + ])); + + $page->assertOk() + ->assertSee('聚水潭订单标签明细') + ->assertSee('260901-391328633273048') + ->assertSee('店铺-拼多多') + ->assertSee('(无标签)') + ->assertSee('id="jst-label-date-from"', false) + ->assertSee('data-jst-labels-clear="jst-label-date-to"', false); + + $filtered = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'billing-transactions', + 'page' => 'jst-order-labels', + 'label' => '京喜', + ])); + $filtered->assertOk() + ->assertSee('3608400015164657') + ->assertDontSee('260901-391328633273048'); + + $export = $this->loginAs()->get(route('business.billing-transactions.jst-order-labels.export', [ + 'label' => '京喜', + ])); + $export->assertOk()->assertDownload(); + $csv = $export->streamedContent(); + $this->assertStringContainsString('线上订单号,店铺名称,下单时间,标签', explode("\n", $csv)[0]); + $this->assertStringContainsString('3608400015164657', $csv); + $this->assertStringNotContainsString('260901-391328633273048', $csv); + } + + private function allowPage(): void + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + } +}