接手 sumu 供应商应付数据库任务 #11
@ -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];
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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();
|
||||
|
||||
110
tests/Unit/EcommerceFinance/SupplierPayableRepositoryTest.php
Normal file
110
tests/Unit/EcommerceFinance/SupplierPayableRepositoryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user