接手 sumu 供应商应付数据库任务 #11

Merged
renshen merged 1 commits from feature/renshen-supplier-payables-database into main 2026-08-06 16:26:52 +08:00
4 changed files with 242 additions and 89 deletions

View File

@ -3,6 +3,8 @@
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\JianSunConnection;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PDO;
/**
@ -11,7 +13,7 @@ use PDO;
* 封装 5 个子页面的数据访问逻辑:
* - returnReconciliations() 退货对账
* - invoiceDeductions() 发票扣款
* - compensationDetails() 赔偿明细(UNION 纸板 + 压板)
* - compensationDetails() 赔偿明细
* - advanceDetails() 借支明细
* - sampleDeductions() 样品扣款
*
@ -21,6 +23,13 @@ class SupplierPayableRepository
{
private ?PDO $db = null;
private readonly UpstreamReadOnlyQuery $query;
public function __construct(UpstreamReadOnlyQueryFactory $queries)
{
$this->query = $queries->forSource('jiansun');
}
private function db(): PDO
{
return $this->db ??= JianSunConnection::get();
@ -257,90 +266,85 @@ class SupplierPayableRepository
}
// ============================================================
// 3. 赔偿明细 — fys_paper_factory + fys_pressing_factory UNION
// 3. 赔偿明细 — fys_supplierPaymentSummary
// ============================================================
public function compensationDetails(array $filters, int $page, int $pageSize): array
{
$where = [];
$params = [];
[$whereSql, $bindings] = $this->compensationWhere($filters);
$page = max($page, 1);
$pageSize = min(max($pageSize, 1), 100);
$offset = ($page - 1) * $pageSize;
$total = (int) ($this->query->scalar(
"SELECT COUNT(*) FROM [dbo].[fys_supplierPaymentSummary] WHERE $whereSql",
$bindings,
) ?? 0);
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
if (!empty($filters['month_from'])) {
$where[] = 'month >= ?';
$params[] = $filters['month_from'];
}
if (!empty($filters['month_to'])) {
$where[] = 'month <= ?';
$params[] = $filters['month_to'];
$rows = $this->query->select(
"SELECT
[Month] AS month,
[supplier] AS supplier,
CAST([Totalindemnity] AS decimal(18,2)) AS amount,
[reason] AS reason,
[remarks] AS remark
FROM [dbo].[fys_supplierPaymentSummary]
WHERE $whereSql
ORDER BY [Month] DESC, [id] DESC
OFFSET $offset ROWS FETCH NEXT $pageSize ROWS ONLY",
$bindings,
);
foreach ($rows as &$row) {
$row['amount'] = (float) ($row['amount'] ?? 0);
}
unset($row);
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
month,
supplier,
CAST(compensation_amount AS decimal(18,2)) AS amount,
reason,
'' AS remark,
creator,
CONVERT(varchar(19), create_time, 120) AS create_time,
'纸板厂' AS factory_type
FROM (
SELECT
month,
factory_subject_name AS supplier,
compensation_amount,
reason,
creator,
create_time
FROM fys_paper_factory_compensation_detail
UNION ALL
SELECT
month,
factory_subject_name AS supplier,
compensation_amount,
reason,
creator,
create_time
FROM fys_pressing_factory_compensation_detail
) AS t
$whereSql";
return $this->runQuery($sql, $params, $page, $pageSize, 'month DESC, create_time DESC');
return ['data' => $rows, 'total' => $total];
}
public function compensationDetailSummary(array $filters): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
ISNULL(SUM(compensation_amount), 0) AS total_amount,
COUNT(*) AS total_count
FROM (
SELECT compensation_amount, factory_subject_name AS supplier FROM fys_paper_factory_compensation_detail
UNION ALL
SELECT compensation_amount, factory_subject_name AS supplier FROM fys_pressing_factory_compensation_detail
) AS t
$whereSql";
$stmt = $this->db()->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
[$whereSql, $bindings] = $this->compensationWhere($filters);
$row = $this->query->first(
"SELECT
ISNULL(SUM([Totalindemnity]), 0) AS total_amount,
COUNT(*) AS total_count
FROM [dbo].[fys_supplierPaymentSummary]
WHERE $whereSql",
$bindings,
) ?? [];
return [
'totalAmount' => (float) $row['total_amount'],
'totalCount' => (int) $row['total_count'],
'totalAmount' => (float) ($row['total_amount'] ?? 0),
'totalCount' => (int) ($row['total_count'] ?? 0),
];
}
/** @return array{0:string,1:array<int,string>} */
private function compensationWhere(array $filters): array
{
$where = ['[Totalindemnity] IS NOT NULL', '[Totalindemnity] <> 0'];
$bindings = [];
if (! empty($filters['supplier'])) {
$where[] = "[supplier] LIKE ? ESCAPE '\\'";
$bindings[] = '%' . str_replace(
['\\', '%', '_'],
['\\\\', '\\%', '\\_'],
trim((string) $filters['supplier']),
) . '%';
}
if (! empty($filters['month_from'])) {
$where[] = '[Month] >= ?';
$bindings[] = (string) $filters['month_from'];
}
if (! empty($filters['month_to'])) {
$where[] = '[Month] <= ?';
$bindings[] = (string) $filters['month_to'];
}
return [implode(' AND ', $where), $bindings];
}
// ============================================================
// 4. 借支明细 — finance_advanceLoans (4行, 字段不足,部分列显示-)
// ============================================================
@ -682,11 +686,12 @@ class SupplierPayableRepository
public function filterOptionsForCompensation(): array
{
$suppliers = $this->fetchDistinct("SELECT DISTINCT supplier FROM (
SELECT factory_subject_name AS supplier FROM fys_paper_factory_compensation_detail WHERE factory_subject_name IS NOT NULL
UNION
SELECT factory_subject_name AS supplier FROM fys_pressing_factory_compensation_detail WHERE factory_subject_name IS NOT NULL
) AS t ORDER BY supplier");
$suppliers = array_column($this->query->select(
'SELECT DISTINCT TOP 200 [supplier] AS value '
. 'FROM [dbo].[fys_supplierPaymentSummary] '
. 'WHERE [Totalindemnity] IS NOT NULL AND [Totalindemnity] <> 0 AND [supplier] IS NOT NULL '
. 'ORDER BY [supplier]',
), 'value');
return ['suppliers' => $suppliers];
}

View File

@ -1,14 +1,40 @@
<style>
.compensation-detail-page .compensation-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
margin-bottom: 16px;
overflow: hidden;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 8px;
background: var(--vip-border, #d8d2c7);
}
.compensation-detail-page .compensation-summary > div {
padding: 14px 16px;
background: #fff;
}
.compensation-detail-page .compensation-summary strong {
display: block;
margin-top: 4px;
color: var(--vip-content-accent-dark, #856632);
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
</style>
<div id="compensation-detail-app" class="compensation-detail-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">赔付总金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
<div class="compensation-summary">
<div>
<span>赔付总金额</span>
<strong>@{{ formatMoney(summary.totalAmount) }}</strong>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">总记录数</div>
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
<div>
<span>总记录数</span>
<strong>@{{ (summary.totalCount || 0).toLocaleString() }}</strong>
</div>
</div>
@ -31,7 +57,6 @@
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="month" label="日期" width="100" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="供应商" width="260" show-overflow-tooltip></el-table-column>
<el-table-column prop="amount" label="赔付金额" width="140" align="right">
@ -43,12 +68,6 @@
<el-table-column prop="remark" label="备注" width="180" show-overflow-tooltip>
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.remark || '-' }}</span></template>
</el-table-column>
<el-table-column prop="factory_type" label="类型" width="100">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.factory_type === '纸板厂' ? 'primary' : 'success'">@{{ scope.row.factory_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator" label="创建人" width="100"></el-table-column>
</el-table>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">

View File

@ -105,6 +105,25 @@ class SupplierPayablePagesTest extends FeatureTestCase
$this->assertCount(34, $columns[0]);
}
public function testCompensationPageUsesTheFiveColumnsFromTheReferenceSheet(): void
{
$this->grantAdminPermission();
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-payables',
'page' => 'compensation-details',
]));
$response
->assertOk()
->assertSee('id="compensation-detail-app"', false)
->assertSee('赔付原因')
->assertSee('备注')
->assertSee('public/js/ecommerce-finance/compensation-details.min.js', false);
$this->assertSame(5, substr_count($response->getContent(), '<el-table-column'));
}
public function testDataBackedSupplierPayableListRoutesAreAvailableToWorkspaceAjaxRequests()
{
$this->grantAdminPermission();

View File

@ -0,0 +1,110 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\TestCase;
class SupplierPayableRepositoryTest extends TestCase
{
public function test_compensation_queries_use_the_supplier_payment_summary_contract(): void
{
$query = $this->createMock(UpstreamReadOnlyQuery::class);
$bindings = ['%Factory\\%\\_%', '2026-07', '2026-08'];
$query->expects($this->once())
->method('scalar')
->with(
$this->callback(function (string $sql): bool {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('[dbo].[fys_supplierPaymentSummary]', $sql);
$this->assertStringContainsString('[Totalindemnity] <> 0', $sql);
return true;
}),
$bindings,
)
->willReturn(1);
$query->expects($this->once())
->method('select')
->with(
$this->callback(function (string $sql): bool {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('[Totalindemnity] AS decimal(18,2)', $sql);
$this->assertStringContainsString('[remarks] AS remark', $sql);
$this->assertStringContainsString('OFFSET 25 ROWS FETCH NEXT 25 ROWS ONLY', $sql);
$this->assertStringNotContainsString('fys_paper_factory_compensation_detail', $sql);
return true;
}),
$bindings,
)
->willReturn([[
'month' => '2026-07',
'supplier' => 'Factory%_',
'amount' => '12.50',
'reason' => 'Reason',
'remark' => 'Remark',
]]);
$result = $this->repository($query)->compensationDetails([
'supplier' => 'Factory%_',
'month_from' => '2026-07',
'month_to' => '2026-08',
], 2, 25);
$this->assertSame(1, $result['total']);
$this->assertSame(12.5, $result['data'][0]['amount']);
$this->assertSame('Remark', $result['data'][0]['remark']);
}
public function test_compensation_summary_and_options_are_bounded_read_only_queries(): void
{
$query = $this->createMock(UpstreamReadOnlyQuery::class);
$query->expects($this->once())
->method('first')
->with(
$this->callback(function (string $sql): bool {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('SUM([Totalindemnity])', $sql);
return true;
}),
[],
)
->willReturn(['total_amount' => '20.25', 'total_count' => '2']);
$query->expects($this->once())
->method('select')
->with($this->callback(function (string $sql): bool {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('DISTINCT TOP 200', $sql);
return true;
}))
->willReturn([['value' => 'Supplier A']]);
$repository = $this->repository($query);
$this->assertSame(
['totalAmount' => 20.25, 'totalCount' => 2],
$repository->compensationDetailSummary([]),
);
$this->assertSame(
['suppliers' => ['Supplier A']],
$repository->filterOptionsForCompensation(),
);
}
private function repository(UpstreamReadOnlyQuery $query): SupplierPayableRepository
{
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->expects($this->once())
->method('forSource')
->with('jiansun')
->willReturn($query);
return new SupplierPayableRepository($factory);
}
}