feat: expand installation detail items

This commit is contained in:
chenxi 2026-08-31 19:42:06 +08:00
parent fcc36551a6
commit 7cff0d6940
5 changed files with 223 additions and 8 deletions

View File

@ -163,6 +163,16 @@ class Pages extends Controller
));
}
public function installationDetails(string $guid)
{
$payload = $this->installationDetails->details($guid);
if (! $payload['ready']) {
return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503);
}
return response()->json(['data' => $payload['data']]);
}
protected function viewData(
array $section,
?array $page = null,

View File

@ -29,17 +29,46 @@ class InstallationDetailMirrorRepository
}
$perPage = min(max($perPage, 1), 100);
// 主视图按商品行展开,同一安装单会出现多行;主表只保留每个
// SourceGuid 的最新 Id,商品行通过 details() 单独展开。
$latest = $connection->table($dataset->targetTable())
->selectRaw('MAX(Id) AS id')
->groupBy('SourceGuid');
$query = $connection->table($dataset->targetTable() . ' AS v')
->leftJoin($items->targetTable() . ' AS i', 'i.guid', '=', 'v.SourceGuid')
->select(['v.*', 'i.Id AS detail_id', 'i.guid AS detail_guid', 'i.shopproid', 'i.Spu', 'i.shopskucode',
'i.proCode', 'i.barCode', 'i.skuName', 'i.scmname', 'i.applyItemAmount', 'i.installcount',
'i.settleItemAmount', 'i.acceptItemAmount', 'i.remark', 'i.createTime AS item_created_at', 'i.modifyTime AS item_modified_at']);
->joinSub($latest, 'latest', static fn ($join) => $join->on('latest.id', '=', 'v.Id'))
->select('v.*');
$total = (clone $query)->count();
$rows = $query->orderByDesc('v.Id')->orderByDesc('i.Id')->forPage(max($page, 1), $perPage)->get();
$rows = $query->orderByDesc('v.Id')->forPage(max($page, 1), $perPage)->get();
return ['data' => $rows->map(fn (object $row): array => $this->map((array) $row))->all(), 'total' => $total, 'ready' => true];
}
/** @return array{data:list<array<string,mixed>>,ready:bool} */
public function details(string $guid): array
{
$dataset = $this->datasets->get(self::DATASET);
$items = $this->datasets->get(self::ITEM_DATASET);
$connection = $this->mirror->connection();
if (! $this->ready($dataset, $items, $connection->getName())) {
return ['data' => [], 'ready' => false];
}
$mainExists = $connection->table($dataset->targetTable())
->where('SourceGuid', $guid)
->exists();
if (! $mainExists) {
return ['data' => [], 'ready' => true];
}
$rows = $connection->table($items->targetTable())
->where('guid', $guid)
->orderBy('Id')
->limit(100)
->get();
return ['data' => $rows->map(fn (object $row): array => $this->mapItem((array) $row))->all(), 'ready' => true];
}
private function ready($dataset, $items, string $connection): bool
{
return $this->mirror->ready($dataset) && $this->mirror->ready($items)
@ -55,7 +84,13 @@ class InstallationDetailMirrorRepository
if (! $this->mirror->ready($dataset) || ! Schema::connection($connection->getName())->hasTable($dataset->targetTable())) {
return ['rows' => 0, 'requested' => 0.0, 'settled' => 0.0, 'paid' => 0.0];
}
$row = $connection->table($dataset->targetTable())->selectRaw('COUNT(*) AS row_count, COALESCE(SUM(installcost),0) AS requested, COALESCE(SUM(settleAmount),0) AS settled, COALESCE(SUM(acceptAmount),0) AS paid')->first();
$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'))
->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];
}
@ -104,6 +139,43 @@ class InstallationDetailMirrorRepository
return array_replace(array_fill_keys(array_keys(\App\Models\Business\InstallationDetail::columnLabels()), null), $mapped);
}
/** @param array<string,mixed> $row */
private function mapItem(array $row): array
{
$value = static function (array $row, string ...$names): mixed {
foreach ($names as $name) {
foreach ($row as $key => $value) {
if (strcasecmp((string) $key, $name) === 0) {
return $value;
}
}
}
return null;
};
return [
'transaction_no' => $value($row, 'orderNum', 'ordernum'),
'product_id' => $value($row, 'shopproid'),
'id_owner' => $value($row, 'id_owner', 'idOwner'),
'department_owner' => $value($row, 'department_owner', 'departmentOwner'),
'auxiliary_spu' => $value($row, 'Spu'),
'product_store_code' => $value($row, 'shopskucode'),
'item_no' => $value($row, 'proCode'),
'barcode' => $value($row, 'barCode'),
'product_name' => $value($row, 'skuName'),
'supplier_name' => $value($row, 'scmname'),
'detail_requested_amount' => $value($row, 'applyItemAmount'),
'detail_requested_quantity' => $value($row, 'installcount'),
'detail_settled_amount' => $value($row, 'settleItemAmount'),
'detail_received_amount' => $value($row, 'acceptItemAmount'),
'installation_company' => $value($row, 'installCompanyName'),
'available' => $value($row, 'isAvailable', 'available'),
'quote' => $value($row, 'quote'),
'quote_owner' => $value($row, 'quoteOwner'),
'quote_time' => $value($row, 'quoteTime'),
];
}
private function yesNo(mixed $value): ?string
{
return $value === null ? null : ((int) $value === 1 ? '是' : '否');

View File

@ -200,6 +200,52 @@
background: #f9fafb;
}
.installation-expand {
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid #d8d2c7;
border-radius: 0.375rem;
background: #ffffff;
color: #856632;
cursor: pointer;
}
.installation-detail-row td {
padding: 0.75rem 1rem 1rem 3rem;
background: #fbfaf7;
}
.installation-detail-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.installation-detail-table th,
.installation-detail-table td {
padding: 0.45rem 0.65rem;
border: 1px solid #e5e0d7;
white-space: nowrap;
text-align: left;
}
.installation-detail-table th {
background: #f3efe6;
color: #595959;
font-weight: 600;
}
.installation-detail-empty,
.installation-detail-error {
color: #595959;
padding: 0.5rem 0;
}
.installation-detail-error {
color: #b80000;
}
.biz-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
@ -545,8 +591,15 @@
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td>{{ $row->id }}</td>
<tr data-installation-row data-guid="{{ $row->related_guid }}">
<td>
@if ($page['slug'] === 'installation-details' && $row->related_guid)
<button type="button" class="installation-expand" data-installation-expand aria-expanded="false" title="查看安装商品明细">
<span class="material-icons-outlined" style="font-size:1rem">chevron_right</span>
</button>
@endif
{{ $row->id }}
</td>
@foreach ($descriptor->fields as $field)
@php
$val = $row->{$field['name']};
@ -601,6 +654,59 @@
</table>
</div>
@if ($page['slug'] === 'installation-details')
<script>
(() => {
const detailUrl = @json(route('business.installation-details.details', ['guid' => '__GUID__']));
const columns = [
['transaction_no', '交易单号'], ['product_id', '商品id'], ['id_owner', 'ID负责人'],
['department_owner', '部门负责人'], ['auxiliary_spu', '辅助码Spu'], ['product_store_code', '商品店铺编码'],
['item_no', '货号'], ['barcode', '条形码'], ['product_name', '商品名称'], ['supplier_name', '供应商名称'],
['detail_requested_amount', '申请明细金额'], ['detail_requested_quantity', '申请明细数量'],
['detail_settled_amount', '结算明细金额'], ['detail_received_amount', '收款明细金额'],
['installation_company', '安装公司'], ['available', '是否可用'], ['quote', '报价'],
['quote_owner', '报价人'], ['quote_time', '报价时间'],
];
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[char]));
const renderDetails = (items) => {
if (!items.length) return '<div class="installation-detail-empty">暂无商品明细</div>';
const head = columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`).join('');
const body = items.map((item) => `<tr>${columns.map(([key]) => `<td>${escapeHtml(item[key])}</td>`).join('')}</tr>`).join('');
return `<table class="installation-detail-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
};
document.querySelectorAll('[data-installation-expand]').forEach((button) => {
button.addEventListener('click', async () => {
const row = button.closest('[data-installation-row]');
const existing = row.nextElementSibling;
if (existing?.matches('.installation-detail-row')) {
existing.hidden = !existing.hidden;
button.setAttribute('aria-expanded', String(!existing.hidden));
button.querySelector('.material-icons-outlined').textContent = existing.hidden ? 'chevron_right' : 'expand_more';
return;
}
const detailRow = document.createElement('tr');
detailRow.className = 'installation-detail-row';
detailRow.innerHTML = `<td colspan="{{ count($descriptor->fields) + 1 }}"><div>正在加载明细…</div></td>`;
row.after(detailRow);
button.disabled = true;
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();
detailRow.firstElementChild.innerHTML = renderDetails(payload.data || []);
button.setAttribute('aria-expanded', 'true');
button.querySelector('.material-icons-outlined').textContent = 'expand_more';
} catch (error) {
detailRow.firstElementChild.innerHTML = '<div class="installation-detail-error">明细加载失败,请稍后重试</div>';
} finally {
button.disabled = false;
}
});
});
})();
</script>
@endif
<div class="biz-pagination">
<span>共 {{ $rows->total() }} 条 · 第 {{ $rows->currentPage() }} / {{ $rows->lastPage() }} 页</span>
<span>

View File

@ -117,6 +117,10 @@ Route::get('workspace/courier-payables/courier-information/export', 'Business\Pa
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
->name('business.pages.export');
Route::get('workspace/financial-operations/installation-details/details/{guid}', 'Business\Pages@installationDetails')
->where('guid', '[A-Za-z0-9-]+')
->name('business.installation-details.details');
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
->name('business.pages.show');

View File

@ -29,6 +29,7 @@ class ManagementCrudTest extends FeatureTestCase
'ready' => true, 'total' => 1, 'data' => [array_replace(array_fill_keys(array_keys(InstallationDetail::columnLabels()), null), [
'id' => 1, 'installation_no' => 'INSTALL-TEST-001', 'installation_company' => '测试安装公司',
'store_name' => '测试店铺', 'requested_amount' => '120.50', 'settled_amount' => '100.00', 'item_quantity' => 2,
'related_guid' => 'guid-install-test',
])],
]);
$mirror->shouldReceive('summary')->once()->andReturn(['rows' => 1, 'requested' => 120.50, 'settled' => 100.00, 'paid' => 0.0]);
@ -50,6 +51,28 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('共 1 条');
}
public function testInstallationDetailsEndpointReturnsChildItemsForMainRecord(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('details')->once()->with('guid-install-test')->andReturn([
'ready' => true,
'data' => [[
'transaction_no' => 'INSTALL-TEST-001',
'product_id' => '10035070813217',
'product_name' => '测试商品',
'detail_requested_amount' => '53.59',
'detail_requested_quantity' => 1,
]],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->get(route('business.installation-details.details', ['guid' => 'guid-install-test']))
->assertOk()
->assertJsonPath('data.0.transaction_no', 'INSTALL-TEST-001')
->assertJsonPath('data.0.product_name', '测试商品');
}
public function testCompanyInformationUsesWorkbookHeadersAndReadOnlyUpstreamRows(): void
{
$this->mockUpstreamPage((object) [