补齐安装明细筛选和子表权限校验 #91

Merged
WuYongJie merged 3 commits from fix/WuYongJie-installation-release-checks into main 2026-08-31 22:01:48 +08:00
6 changed files with 177 additions and 19 deletions

View File

@ -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, '安装明细镜像尚未完成首次同步');
}

View File

@ -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(

View File

@ -19,7 +19,7 @@ class InstallationDetailMirrorRepository
}
/** @return array{data:list<array<string,mixed>>,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<string,mixed> $row */
private function map(array $row): array
{

View File

@ -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 @@
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="安装明细筛选查询栏">
@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)
<label class="installation-filter-field">
<span>{{ $filter['label'] }}</span>
<input type="{{ $filter['type'] ?? 'text' }}" name="{{ $filter['name'] }}" value="{{ request($filter['name']) }}">
@if (isset($filter['options']))
<select name="{{ $filter['name'] }}"><option value="">全部</option>@foreach ($filter['options'] as $value => $label)<option value="{{ $value }}" @selected((string) request($filter['name']) === (string) $value)>{{ $label }}</option>@endforeach</select>
@else
<input type="{{ $filter['type'] ?? 'text' }}" name="{{ $filter['name'] }}" value="{{ request($filter['name']) }}" @if (($filter['type'] ?? '') !== 'date') placeholder="按原值精确查询" @endif>
@endif
</label>
@endforeach
<div class="installation-filter-actions">
@ -648,6 +656,7 @@
<table class="biz-table">
<thead>
<tr>
@if ($page['slug'] !== 'installation-details')<th>#</th>@endif
@foreach ($descriptor->fields as $field)
<th class="@if ($field['currency']) num @endif">{{ $field['label'] }}</th>
@endforeach
@ -658,7 +667,8 @@
</thead>
<tbody>
@forelse ($rows as $row)
<tr class="installation-main-row" data-installation-row data-guid="{{ $row->related_guid }}">
<tr @if ($page['slug'] === 'installation-details') class="installation-main-row" data-installation-row data-guid="{{ $row->related_guid ?? '' }}" tabindex="0" aria-label="查看安装商品明细" @endif>
@if ($page['slug'] !== 'installation-details')<td>{{ $row->id }}</td>@endif
@foreach ($descriptor->fields as $field)
@php
$val = $row->{$field['name']};
@ -701,7 +711,7 @@
</tr>
@empty
<tr>
<td colspan="{{ count($descriptor->fields) + ($descriptor->readOnly ? 0 : 1) }}">
<td colspan="{{ count($descriptor->fields) + ($page['slug'] === 'installation-details' ? 0 : 1) + ($descriptor->readOnly ? 0 : 1) }}">
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">inbox</span>
<div>{{ $descriptor->readOnly ? '暂无工作平台数据' : '暂无数据,点击右上角"新增"开始录入' }}</div>
@ -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 = '<div>正在加载明细…</div>';
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', '<div class="installation-detail-empty">当前仅展示前 100 条商品明细,不代表全部。</div>');
} catch (error) {
if (requestId !== detailRequest) return;
detailContent.innerHTML = '<div class="installation-detail-error">明细加载失败,请稍后重试</div>';
}
});

View File

@ -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) [

View File

@ -0,0 +1,90 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class InstallationDetailMirrorRepositoryTest extends TestCase
{
private InstallationDetailMirrorRepository $repository;
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');
$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();
}
}