补齐安装明细筛选和子表权限校验 #91
@ -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, '安装明细镜像尚未完成首次同步');
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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>';
|
||||
}
|
||||
});
|
||||
|
||||
@ -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) [
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user