diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 8062d660..1043fca7 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -48,7 +48,7 @@ class UpstreamBusinessInformation private function installationPage(int $page, int $perPage): array { - $payload = $this->installationMirror->paginate($page, $perPage); + $payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters()); if (! $payload['ready']) { throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步'); } diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 33a730b3..c0917408 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -165,12 +165,14 @@ class Pages extends Controller public function installationDetails(string $guid) { + $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); + abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。'); $payload = $this->installationDetails->details($guid); if (! $payload['ready']) { return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503); } - return response()->json(['data' => $payload['data']]); + return response()->json(['data' => $payload['data'], 'has_more' => $payload['has_more'] ?? false]); } protected function viewData( diff --git a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php index b6f923ea..81929719 100644 --- a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php @@ -19,7 +19,7 @@ class InstallationDetailMirrorRepository } /** @return array{data:list>,total:int,ready:bool} */ - public function paginate(int $page, int $perPage): array + public function paginate(int $page, int $perPage, array $filters = []): array { $connection = $this->mirror->connection(); $dataset = $this->datasets->get(self::DATASET); @@ -37,6 +37,7 @@ class InstallationDetailMirrorRepository $query = $connection->table($dataset->targetTable() . ' AS v') ->joinSub($latest, 'latest', static fn ($join) => $join->on('latest.id', '=', 'v.Id')) ->select('v.*'); + $this->applyFilters($query, $filters, $items->targetTable()); $total = (clone $query)->count(); $rows = $query->orderByDesc('v.Id')->forPage(max($page, 1), $perPage)->get(); @@ -62,14 +63,14 @@ class InstallationDetailMirrorRepository $transactionNo = $connection->table($dataset->targetTable()) ->where('SourceGuid', $guid) - ->value('OrderNum'); + ->orderByDesc('Id')->value('OrderNum'); $rows = $connection->table($items->targetTable()) ->where('guid', $guid) ->orderBy('Id') - ->limit(100) + ->limit(101) ->get(); - return ['data' => $rows->map(fn (object $row): array => $this->mapItem((array) $row, $transactionNo))->all(), 'ready' => true]; + return ['data' => $rows->take(100)->map(fn (object $row): array => $this->mapItem((array) $row, $transactionNo))->all(), 'ready' => true, 'has_more' => $rows->count() > 100]; } private function ready($dataset, $items, string $connection): bool @@ -80,7 +81,7 @@ class InstallationDetailMirrorRepository } /** @return array{rows:int,requested:float,settled:float,paid:float} */ - public function summary(): array + public function summary(array $filters = []): array { $dataset = $this->datasets->get(self::DATASET); $connection = $this->mirror->connection(); @@ -90,13 +91,50 @@ class InstallationDetailMirrorRepository $latest = $connection->table($dataset->targetTable()) ->selectRaw('MAX(Id) AS id') ->groupBy('SourceGuid'); - $row = $connection->table($dataset->targetTable() . ' AS v') - ->joinSub($latest, 'latest', static fn ($join) => $join->on('latest.id', '=', 'v.Id')) + $query = $connection->table($dataset->targetTable() . ' AS v') + ->joinSub($latest, 'latest', static fn ($join) => $join->on('latest.id', '=', 'v.Id')); + $this->applyFilters($query, $filters, $this->datasets->get(self::ITEM_DATASET)->targetTable()); + $row = $query ->selectRaw('COUNT(*) AS row_count, COALESCE(SUM(v.installcost),0) AS requested, COALESCE(SUM(v.settleAmount),0) AS settled, COALESCE(SUM(v.acceptAmount),0) AS paid') ->first(); return ['rows' => (int) $row->row_count, 'requested' => (float) $row->requested, 'settled' => (float) $row->settled, 'paid' => (float) $row->paid]; } + public static function requestFilters(): array + { + $rules = []; + foreach (['created', 'customer_review', 'finance_review'] as $prefix) { + $rules[$prefix . '_from'] = ['nullable', 'date_format:Y-m-d']; + $rules[$prefix . '_to'] = ['nullable', 'date_format:Y-m-d']; + if (request()->filled($prefix . '_from')) $rules[$prefix . '_to'][] = 'after_or_equal:' . $prefix . '_from'; + } + foreach (['push_status','order_no','customer_review','finance_review','quote_status','arranged_status','fee_payer','completed_status','shop_name','delete_status','installation_company','installation_no','order_type','product_id'] as $key) { + $rules[$key] = ['nullable', 'string', 'max:191']; + } + foreach (['customer_review', 'finance_review', 'arranged_status', 'completed_status', 'delete_status'] as $key) $rules[$key][] = 'in:0,1'; + return validator(request()->only(array_keys($rules)), $rules)->validate(); + } + + private function applyFilters($query, array $filters, string $itemTable): void + { + foreach (['created'=>'SubmitTime', 'customer_review'=>'kfshTime', 'finance_review'=>'cwshTime'] as $prefix=>$column) { + if (!empty($filters[$prefix.'_from'])) $query->where('v.'.$column, '>=', $filters[$prefix.'_from']); + if (!empty($filters[$prefix.'_to'])) $query->where('v.'.$column, '<', \Illuminate\Support\Carbon::parse($filters[$prefix.'_to'])->addDay()->toDateString()); + } + foreach (['push_status'=>'apistatus','order_no'=>'OrderNum','customer_review'=>'kfsh','finance_review'=>'cwsh','quote_status'=>'BidStatus','fee_payer'=>'costtaker','completed_status'=>'installComplete','shop_name'=>'shop_name','delete_status'=>'Delete','installation_company'=>'installCompanyName','installation_no'=>'ordernum_id','order_type'=>'TypeDto'] as $key=>$column) { + if (isset($filters[$key]) && $filters[$key] !== '') $query->where('v.'.$column, $filters[$key]); + } + if (isset($filters['arranged_status']) && $filters['arranged_status'] !== '') { + $query->where('v.StatusId', $filters['arranged_status'] === '1' ? '>' : '<=', 0); + } + if (!empty($filters['product_id'])) { + $query->whereExists(function ($sub) use ($itemTable, $filters) { + $sub->selectRaw('1')->from($itemTable.' AS filter_item') + ->whereColumn('filter_item.guid', 'v.SourceGuid')->where('filter_item.shopproid', $filters['product_id']); + }); + } + } + /** @param array $row */ private function map(array $row): array { diff --git a/resources/views/business/pages/show.blade.php b/resources/views/business/pages/show.blade.php index e4566b8f..cb97124b 100644 --- a/resources/views/business/pages/show.blade.php +++ b/resources/views/business/pages/show.blade.php @@ -166,7 +166,8 @@ flex: 0 0 auto; } - .installation-filter-field input { + .installation-filter-field input, + .installation-filter-field select { min-width: 0; width: 100%; height: 2.1rem; @@ -187,6 +188,9 @@ @media (max-width: 1100px) { .installation-filter-bar { grid-template-columns: repeat(2, minmax(14rem, 1fr)); } } + @media (max-width: 650px) { + .installation-filter-bar { grid-template-columns: minmax(0, 1fr); padding: 1rem; } + } .biz-card-head { display: flex; @@ -585,14 +589,14 @@
@php $filterFields = [ - ['name' => 'created_from', 'label' => '创建日期:', 'type' => 'date'], ['name' => 'created_to', 'label' => '至:', 'type' => 'date'], + ['name' => 'created_from', 'label' => '提交日期:', 'type' => 'date'], ['name' => 'created_to', 'label' => '至:', 'type' => 'date'], ['name' => 'customer_review_from', 'label' => '客服审核时间:', 'type' => 'date'], ['name' => 'customer_review_to', 'label' => '至:', 'type' => 'date'], ['name' => 'finance_review_from', 'label' => '财务审核时间:', 'type' => 'date'], ['name' => 'finance_review_to', 'label' => '至:', 'type' => 'date'], ['name' => 'push_status', 'label' => '推送状态:'], ['name' => 'order_no', 'label' => '订单编号:'], - ['name' => 'customer_review', 'label' => '客服审核:'], ['name' => 'finance_review', 'label' => '财务审核:'], - ['name' => 'quote_status', 'label' => '报价状态:'], ['name' => 'arranged_status', 'label' => '是否安排:'], - ['name' => 'fee_payer', 'label' => '安装费承担方:'], ['name' => 'completed_status', 'label' => '是否完工:'], - ['name' => 'shop_name', 'label' => '选择店铺:'], ['name' => 'delete_status', 'label' => '选择删除:'], + ['name' => 'customer_review', 'label' => '客服审核:', 'options' => ['0'=>'未审核','1'=>'已审核']], ['name' => 'finance_review', 'label' => '财务审核:', 'options' => ['0'=>'未审核','1'=>'已审核']], + ['name' => 'quote_status', 'label' => '报价状态:'], ['name' => 'arranged_status', 'label' => '是否安排:', 'options' => ['0'=>'未安排','1'=>'已安排']], + ['name' => 'fee_payer', 'label' => '安装费承担方:'], ['name' => 'completed_status', 'label' => '是否完工:', 'options' => ['0'=>'未完工','1'=>'已完工']], + ['name' => 'shop_name', 'label' => '选择店铺:'], ['name' => 'delete_status', 'label' => '选择删除:', 'options' => ['0'=>'未删除','1'=>'已删除']], ['name' => 'installation_company', 'label' => '安装公司:'], ['name' => 'installation_no', 'label' => '安装单号:'], ['name' => 'order_type', 'label' => '订单类型:'], ['name' => 'product_id', 'label' => '商品ID:'], ]; @@ -600,7 +604,11 @@ @foreach ($filterFields as $filter) @endforeach
@@ -648,6 +656,7 @@ + @if ($page['slug'] !== 'installation-details')@endif @foreach ($descriptor->fields as $field) @endforeach @@ -658,7 +667,8 @@ @forelse ($rows as $row) - + + @if ($page['slug'] !== 'installation-details')@endif @foreach ($descriptor->fields as $field) @php $val = $row->{$field['name']}; @@ -701,7 +711,7 @@ @empty -
#{{ $field['label'] }}
{{ $row->id }}
+
{{ $descriptor->readOnly ? '暂无工作平台数据' : '暂无数据,点击右上角"新增"开始录入' }}
@@ -743,16 +753,24 @@ }; const detailPanel = document.querySelector('[data-installation-detail-panel]'); const detailContent = detailPanel?.querySelector('[data-installation-detail-content]'); + let detailRequest = 0; document.querySelectorAll('[data-installation-row]').forEach((row) => { + row.addEventListener('keydown', (event) => { + if (event.target === row && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); row.click(); } + }); row.addEventListener('click', async () => { if (!detailContent) return; + const requestId = ++detailRequest; detailContent.innerHTML = '
正在加载明细…
'; try { const response = await fetch(detailUrl.replace('__GUID__', encodeURIComponent(row.dataset.guid)), {headers: {'Accept': 'application/json'}}); if (!response.ok) throw new Error('明细加载失败'); const payload = await response.json(); + if (requestId !== detailRequest) return; detailContent.innerHTML = renderDetails(payload.data || []); + if (payload.has_more) detailContent.insertAdjacentHTML('beforeend', '
当前仅展示前 100 条商品明细,不代表全部。
'); } catch (error) { + if (requestId !== detailRequest) return; detailContent.innerHTML = '
明细加载失败,请稍后重试
'; } }); diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index 07250611..cdcb3250 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -44,7 +44,7 @@ class ManagementCrudTest extends FeatureTestCase ->assertSeeText('安装单号') ->assertSeeText('申请总金额') ->assertSeeText('商品名称(1)') - ->assertSeeText('创建日期:') + ->assertSeeText('提交日期:') ->assertSeeText('客服审核时间:') ->assertSeeText('财务审核时间:') ->assertSeeText('推送状态:') @@ -80,6 +80,16 @@ class ManagementCrudTest extends FeatureTestCase ->assertJsonPath('data.0.product_name', '测试商品'); } + public function testInstallationChildEndpointRequiresTheSameSidebarPermission(): void + { + $this->withExceptionHandling(); + setting()->set('system.sidebar_permissions', ['configured'=>['user_'.$this->user->id], 'subjects'=>['user_'.$this->user->id=>['store-management/basic-information']]]); + $mirror=Mockery::mock(InstallationDetailMirrorRepository::class); + $mirror->shouldNotReceive('details'); + $this->app->instance(InstallationDetailMirrorRepository::class,$mirror); + $this->loginAs()->getJson(route('business.installation-details.details',['guid'=>'guid-install-test']))->assertForbidden(); + } + public function testCompanyInformationUsesWorkbookHeadersAndReadOnlyUpstreamRows(): void { $this->mockUpstreamPage((object) [ diff --git a/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php new file mode 100644 index 00000000..9a35ef01 --- /dev/null +++ b/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php @@ -0,0 +1,90 @@ +['driver'=>'sqlite','database'=>':memory:','prefix'=>''], 'upstream_mirrors.connection'=>'business_mirror']); + DB::purge('business_mirror'); + $definitions=[]; + foreach ([InstallationDetailMirrorRepository::DATASET=>'fixture_install',InstallationDetailMirrorRepository::ITEM_DATASET=>'fixture_items'] as $key=>$table) { + $definitions[$key]=['source_key'=>'jiansun','schema'=>'dbo','object'=>explode('.',$key)[2],'target'=>$table,'keys'=>['Id']]; + } + config(['upstream_mirrors.datasets'=>$definitions]); + $schema=Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states',function(Blueprint $t){$t->string('dataset_key')->primary();$t->dateTime('initial_completed_at')->nullable();}); + $schema->create('fixture_install',function(Blueprint $t){ + $t->integer('Id'); + foreach(['SourceGuid','OrderNum','ordernum_id','shop_name','SubmitTime','kfshTime','cwshTime','apistatus','kfsh','cwsh','BidStatus','costtaker','installComplete','Delete','installCompanyName','TypeDto'] as $c) $t->string($c)->nullable(); + $t->integer('StatusId')->default(0); + foreach(['installcost','settleAmount','acceptAmount'] as $c) $t->decimal($c,14,2)->default(0); + }); + $schema->create('fixture_items',function(Blueprint $t){$t->integer('Id');$t->string('guid');$t->string('shopproid');$t->string('Sourceno')->nullable();}); + foreach(array_keys($definitions) as $key) DB::connection('business_mirror')->table('business_mirror_table_states')->insert(['dataset_key'=>$key,'initial_completed_at'=>'2026-08-01']); + DB::connection('business_mirror')->table('fixture_install')->insert([ + ['Id'=>1,'SourceGuid'=>'A','OrderNum'=>'OLD','shop_name'=>'旧店','SubmitTime'=>'2026-08-01','installcost'=>500], + ['Id'=>2,'SourceGuid'=>'A','OrderNum'=>'NEW','shop_name'=>'测试店','SubmitTime'=>'2026-08-20 23:59:59','installcost'=>20], + ['Id'=>3,'SourceGuid'=>'B','OrderNum'=>'OTHER','shop_name'=>'其他店','SubmitTime'=>'2026-08-20','installcost'=>30], + ['Id'=>4,'SourceGuid'=>'C','OrderNum'=>'NEXT','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','installcost'=>40], + ]); + DB::connection('business_mirror')->table('fixture_items')->insert([ + ['Id'=>1,'guid'=>'A','shopproid'=>'PRODUCT-A'],['Id'=>2,'guid'=>'A','shopproid'=>'PRODUCT-A'], + ]); + $this->repository=new InstallationDetailMirrorRepository(new GenericBusinessMirrorRepository(app('db')),new BusinessMirrorDatasetRegistry()); + } + + public function test_filters_apply_to_deduplicated_list_count_and_summary(): void + { + $filters=['shop_name'=>'测试店']; + $page=$this->repository->paginate(2,1,$filters); + $this->assertSame(2,$page['total']); + $this->assertSame(2,$page['data'][0]['id']); + $this->assertSame(60.0,$this->repository->summary($filters)['requested']); + $filters['created_to']='2026-08-20'; + $this->assertSame(1,$this->repository->paginate(1,50,$filters)['total']); + $this->assertSame(20.0,$this->repository->summary($filters)['requested']); + $this->assertSame(0,$this->repository->paginate(1,50,['shop_name'=>"' OR 1=1 --"])['total']); + } + + public function test_product_filter_does_not_multiply_main_rows(): void + { + $filters=['product_id'=>'PRODUCT-A']; + $this->assertSame(1,$this->repository->paginate(1,50,$filters)['total']); + $this->assertSame(20.0,$this->repository->summary($filters)['requested']); + $this->assertSame('NEW',$this->repository->details('A')['data'][0]['transaction_no']); + $this->assertSame([],$this->repository->details('missing')['data']); + } + + public function test_status_filters_and_child_limit_are_explicit(): void + { + DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update(['kfsh'=>'1','Delete'=>'0','StatusId'=>2]); + $this->assertSame(1,$this->repository->paginate(1,50,['customer_review'=>'1','delete_status'=>'0','arranged_status'=>'1'])['total']); + $rows=[];for($i=1;$i<=101;$i++)$rows[]=['Id'=>$i+10,'guid'=>'B','shopproid'=>'P'.$i]; + DB::connection('business_mirror')->table('fixture_items')->insert($rows); + $result=$this->repository->details('B'); + $this->assertCount(100,$result['data']); + $this->assertTrue($result['has_more']); + } + + public function test_end_date_alone_is_valid_and_invalid_status_is_rejected(): void + { + request()->merge(['created_to'=>'2026-08-20']); + $this->assertSame(['created_to'=>'2026-08-20'],InstallationDetailMirrorRepository::requestFilters()); + request()->merge(['arranged_status'=>'unexpected']); + $this->expectException(\Illuminate\Validation\ValidationException::class); + InstallationDetailMirrorRepository::requestFilters(); + } +}