功能:安全整合封边条上游数据

This commit is contained in:
WuYongJie 2026-07-24 18:34:09 +08:00
parent d9590ae28d
commit 5045fadfe8
14 changed files with 932 additions and 110 deletions

View File

@ -9,11 +9,21 @@ use App\Utilities\SidebarPermissions;
use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
class Pages extends Controller
{
private const EDGE_BANDING_SOURCE = 'jiansun';
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
) {
}
/**
* 支持的营销账户平台列表
@ -92,6 +102,7 @@ class Pages extends Controller
'tabs' => $tabs,
'activeTab' => null,
'content' => $content,
'edgeBanding' => $this->edgeBandingContent($page),
]);
}
@ -111,6 +122,7 @@ class Pages extends Controller
'tabs' => $tabs,
'activeTab' => $activeTab,
'content' => $content,
'edgeBanding' => $this->edgeBandingContent($page),
]);
}
@ -126,6 +138,103 @@ class Pages extends Controller
};
}
/**
* 封边条页面只读取上游快照;未启用或无快照时将错误交给页面显示,
* 避免工作台因上游暂不可用直接返回 500。
*/
protected function edgeBandingContent(string $page): ?array
{
$scope = match ($page) {
'edge-banding-prices' => 'edge-banding.prices',
'details' => 'edge-banding.details',
'edge-banding-payables' => 'edge-banding.payables',
default => null,
};
if ($scope === null) {
return null;
}
$filters = match ($page) {
'edge-banding-prices' => $this->edgeBandingFilters([
'raw_name', 'raw_code', 'production_code', 'supplier',
]),
'details' => $this->edgeBandingFilters([
'date', 'production_code', 'raw_name', 'return_flag',
'outbound_supplier', 'arrival_supplier', 'outbound_no',
]),
'edge-banding-payables' => $this->edgeBandingFilters(['period', 'supplier']),
default => [],
};
if ($page === 'edge-banding-payables' && ! isset($filters['period'])) {
$filters['period'] = '2026-06';
}
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource(self::EDGE_BANDING_SOURCE);
try {
$result = $cache->remember(
$scope,
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => match ($scope) {
'edge-banding.prices' => $this->edgeBanding->prices($filters, $pageNumber, $limit),
'edge-banding.details' => $this->edgeBanding->details($filters, $pageNumber, $limit),
'edge-banding.payables' => $this->edgeBanding->payables($filters, $pageNumber, $limit),
},
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0],
'page' => $pageNumber,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0],
'page' => $pageNumber,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => '封边条数据暂时无法读取,请稍后重试。',
];
}
}
private function edgeBandingFilters(array $fields): array
{
$filters = [];
foreach ($fields as $field) {
$value = trim((string) request($field, ''));
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
/**
* 种菜应收对账 - 加载对账数据(21字段)
*/
@ -208,7 +317,7 @@ class Pages extends Controller
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(PinduoduoSpreadBillRepository::class);
$cache = app(UpstreamQueryCacheFactory::class)->forSource('jiansun');
$cache = $this->upstreamCaches->forSource('jiansun');
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,

View File

@ -25,7 +25,10 @@ return [
'database' => env('DB_JIANSUN_DATABASE'),
'username' => env('DB_JIANSUN_USERNAME'),
'password' => env('DB_JIANSUN_PASSWORD'),
'odbc_driver' => env('DB_JIANSUN_ODBC_DRIVER', 'FreeTDS'),
'odbc_driver' => env(
'DB_JIANSUN_ODBC_DRIVER',
PHP_OS_FAMILY === 'Windows' ? 'ODBC Driver 18 for SQL Server' : 'FreeTDS'
),
'tds_version' => env('DB_JIANSUN_TDS_VERSION', '7.4'),
// 旧 SQL Server 的可选 TLS 预登录不稳定;仅本地开发默认关闭协商。
'encryption' => env(

View File

@ -24,7 +24,10 @@ return [
'database' => env('DB_OPERATIONS_REPLICA_DATABASE'),
'username' => env('DB_OPERATIONS_REPLICA_USERNAME'),
'password' => env('DB_OPERATIONS_REPLICA_PASSWORD'),
'odbc_driver' => env('DB_OPERATIONS_REPLICA_ODBC_DRIVER', 'FreeTDS'),
'odbc_driver' => env(
'DB_OPERATIONS_REPLICA_ODBC_DRIVER',
PHP_OS_FAMILY === 'Windows' ? 'ODBC Driver 18 for SQL Server' : 'FreeTDS'
),
'tds_version' => env('DB_OPERATIONS_REPLICA_TDS_VERSION', '7.4'),
// 旧 SQL Server 的可选 TLS 预登录不稳定;仅本地开发默认关闭协商。
'encryption' => env(

View File

@ -0,0 +1,293 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/**
* 原材料-封边条只读数据仓库。
*
* 数据源只暴露固定的 SELECT,页面请求通过 JianSunQueryCache 做快照、限流
* 和熔断。源表中不存在的展示字段使用空值,不从演示数据倒灌真实业务数据。
*/
class EdgeBandingRepository
{
private const SOURCE = 'jiansun';
private ?UpstreamReadOnlyQuery $query = null;
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
public function prices(array $filters, int $page, int $limit): array
{
$where = ["p.number LIKE 'FBT%'"];
$bindings = [];
if (($filters['raw_name'] ?? '') !== '') {
$where[] = 'p.name LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['raw_name']);
}
if (($filters['raw_code'] ?? '') !== '') {
$where[] = 'p.number LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['raw_code']);
}
if (($filters['production_code'] ?? '') !== '') {
$where[] = "CONCAT(p.number, 'F01') LIKE ? ESCAPE '\\'";
$bindings[] = $this->contains($filters['production_code']);
}
if (($filters['supplier'] ?? '') !== '') {
$where[] = 'p.supplier LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['supplier']);
}
$whereSql = implode(' AND ', $where);
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$offset = ($page - 1) * $limit;
$sql = "WITH source_rows AS (
SELECT
p.name AS raw_name,
p.number AS raw_code,
CONCAT(p.number, 'F01') AS production_code,
CAST(p.price AS decimal(18,2)) AS latest_price,
p.supplier AS target_supplier,
ROW_NUMBER() OVER (ORDER BY p.number, p.supplier) AS row_no,
COUNT(*) OVER () AS total_count
FROM [dbo].[pm_averageUnitPrice] p
WHERE {$whereSql}
)
SELECT raw_name, raw_code, production_code,
latest_price, target_supplier, total_count
FROM source_rows
WHERE row_no BETWEEN ? AND ?
ORDER BY row_no";
$rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit]));
foreach ($rows as &$row) {
$row['price_type'] = '采购价';
$row['factory_type'] = '封边条厂';
}
return $this->pageResult($rows);
}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
public function details(array $filters, int $page, int $limit): array
{
$where = ["s.procode LIKE 'FBT%'"];
$bindings = [];
if (($filters['date'] ?? '') !== '') {
$where[] = 'CONVERT(date, s.sendTime) = ?';
$bindings[] = $filters['date'];
}
if (($filters['production_code'] ?? '') !== '') {
$where[] = 's.procode LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['production_code']);
}
if (($filters['raw_name'] ?? '') !== '') {
$where[] = 's.skuname LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['raw_name']);
}
if (($filters['return_flag'] ?? '') === 'return') {
$where[] = 'ISNULL(s.settlementStock, 0) < 0';
} elseif (($filters['return_flag'] ?? '') === 'normal') {
$where[] = 'ISNULL(s.settlementStock, 0) >= 0';
}
if (($filters['outbound_supplier'] ?? '') !== '') {
$where[] = 's.supplier LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['outbound_supplier']);
}
if (($filters['arrival_supplier'] ?? '') !== '') {
$where[] = 's.whName LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['arrival_supplier']);
}
if (($filters['outbound_no'] ?? '') !== '') {
// 现有结算表没有单独的出库单号,快递/送货号作为可检索的外部单号。
$where[] = 'ISNULL(s.expressNo, \'\') LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['outbound_no']);
}
$whereSql = implode(' AND ', $where);
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$offset = ($page - 1) * $limit;
$sql = "WITH source_rows AS (
SELECT
CONVERT(varchar(19), s.sendTime, 120) AS inbound_date,
s.procode AS production_code,
s.skuname AS raw_name,
CAST(ISNULL(s.settlementStock, 0) AS decimal(18,2)) AS outbound_quantity,
s.supplier AS outbound_supplier,
ISNULL(s.whName, '') AS arrival_supplier,
CONVERT(varchar(10), s.sendTime, 120) AS batch_date,
'' AS outbound_no,
ISNULL(s.remark, '') AS outbound_remark,
'' AS requisition_no,
ISNULL(s.expressNo, '') AS delivery_no,
CAST(ISNULL(s.settlementStock, 0) AS decimal(18,2)) AS received_quantity,
CAST(ISNULL(s.settlementPrice, 0) AS decimal(18,2)) AS reconciliation_amount,
CAST(0 AS decimal(18,2)) AS freight,
CAST(ISNULL(a.price, 0) AS decimal(18,2)) AS edge_band_unit_price,
CAST(ISNULL(s.settlementStock, 0) * ISNULL(a.price, 0) AS decimal(18,2)) AS payable_amount,
CAST(ISNULL(s.singlePrice, 0) AS decimal(18,2)) AS furniture_unit_price,
CAST(ISNULL(s.settlementPrice, 0) AS decimal(18,2)) AS furniture_amount,
CAST(ISNULL(s.singlePrice, 0) - ISNULL(a.price, 0) AS decimal(18,2)) AS price_difference,
CAST(ISNULL(s.settlementPrice, 0) - (ISNULL(s.settlementStock, 0) * ISNULL(a.price, 0)) AS decimal(18,2)) AS profit,
ROW_NUMBER() OVER (ORDER BY s.sendTime DESC, s.Id DESC) AS row_no,
COUNT(*) OVER () AS total_count
FROM [dbo].[fys_supplierGoods_sttlement_sale] s
LEFT JOIN [dbo].[pm_averageUnitPrice] a
ON a.number = s.procode AND a.supplier = s.supplier
WHERE {$whereSql}
)
SELECT inbound_date, production_code, raw_name, outbound_quantity,
outbound_supplier, arrival_supplier, batch_date,
outbound_no, outbound_remark, requisition_no, delivery_no,
received_quantity, reconciliation_amount, freight, edge_band_unit_price,
payable_amount, furniture_unit_price, furniture_amount, price_difference,
profit, total_count
FROM source_rows
WHERE row_no BETWEEN ? AND ?
ORDER BY row_no";
$rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit]));
foreach ($rows as &$row) {
$row['unit'] = '米';
$row['return_flag'] = (float) ($row['outbound_quantity'] ?? 0) < 0 ? '退货' : '正常出库';
$row['number_type'] = '申领';
}
return $this->pageResult($rows);
}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
public function payables(array $filters, int $page, int $limit): array
{
$where = [
"EXISTS (
SELECT 1
FROM [dbo].[pm_averageUnitPrice] edge_price
WHERE edge_price.number LIKE 'FBT%'
AND edge_price.supplier = p.supplier
)",
];
$bindings = [];
if (($filters['period'] ?? '') !== '') {
$where[] = 'p.Month = ?';
$bindings[] = $filters['period'];
}
if (($filters['supplier'] ?? '') !== '') {
$where[] = 'p.supplier LIKE ? ESCAPE \'\\\'';
$bindings[] = $this->contains($filters['supplier']);
}
$whereSql = implode(' AND ', $where);
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$offset = ($page - 1) * $limit;
$sql = "WITH source_rows AS (
SELECT
p.supplier,
CAST(SUM(ISNULL(p.bginMonthPay, 0)) AS decimal(18,2)) AS month_start_unpaid,
CAST(SUM(ISNULL(p.loan, 0)) AS decimal(18,2)) AS goods_amount,
CAST(SUM(ISNULL(p.ExpFieght, 0)) AS decimal(18,2)) AS prepaid_freight,
CAST(SUM(ISNULL(p.Totalindemnity, 0)) AS decimal(18,2)) AS compensation,
CAST(SUM(ISNULL(p.supplySup, 0)) AS decimal(18,2)) AS supplier_refund,
CAST(SUM(ISNULL(p.ShouldPay, 0)) AS decimal(18,2)) AS payable_amount,
CAST(SUM(ISNULL(p.factPay, 0)) AS decimal(18,2)) AS paid_amount,
CAST(SUM(ISNULL(p.cashierbypaid, 0)) AS decimal(18,2)) AS actual_payment,
CAST(SUM(ISNULL(p.toPrivate, 0)) AS decimal(18,2)) AS private_payment,
CAST(SUM(ISNULL(p.toCorporate, 0)) AS decimal(18,2)) AS corporate_payment,
MAX(ISNULL(p.remarks, '')) AS remark,
CAST(SUM(SUM(ISNULL(p.bginMonthPay, 0))) OVER () AS decimal(18,2)) AS summary_month_start_unpaid,
CAST(SUM(SUM(ISNULL(p.loan, 0))) OVER () AS decimal(18,2)) AS summary_goods_amount,
CAST(SUM(SUM(ISNULL(p.ExpFieght, 0))) OVER () AS decimal(18,2)) AS summary_prepaid_freight,
CAST(SUM(SUM(ISNULL(p.Totalindemnity, 0))) OVER () AS decimal(18,2)) AS summary_compensation,
CAST(SUM(SUM(ISNULL(p.supplySup, 0))) OVER () AS decimal(18,2)) AS summary_supplier_refund,
CAST(SUM(SUM(ISNULL(p.ShouldPay, 0))) OVER () AS decimal(18,2)) AS summary_payable_amount,
CAST(SUM(SUM(ISNULL(p.factPay, 0))) OVER () AS decimal(18,2)) AS summary_paid_amount,
CAST(SUM(SUM(ISNULL(p.cashierbypaid, 0))) OVER () AS decimal(18,2)) AS summary_actual_payment,
CAST(SUM(SUM(ISNULL(p.toPrivate, 0))) OVER () AS decimal(18,2)) AS summary_private_payment,
CAST(SUM(SUM(ISNULL(p.toCorporate, 0))) OVER () AS decimal(18,2)) AS summary_corporate_payment,
ROW_NUMBER() OVER (ORDER BY p.supplier) AS row_no,
COUNT(*) OVER () AS total_count
FROM [dbo].[fys_supplierPaymentSummary] p
WHERE {$whereSql}
GROUP BY p.supplier
)
SELECT supplier, month_start_unpaid, goods_amount, prepaid_freight,
compensation, supplier_refund, payable_amount, paid_amount,
actual_payment, private_payment, corporate_payment, remark,
summary_month_start_unpaid, summary_goods_amount,
summary_prepaid_freight, summary_compensation,
summary_supplier_refund, summary_payable_amount,
summary_paid_amount, summary_actual_payment,
summary_private_payment, summary_corporate_payment,
total_count
FROM source_rows
WHERE row_no BETWEEN ? AND ?
ORDER BY row_no";
$rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit]));
return $this->payablesPageResult($rows);
}
private function query(): UpstreamReadOnlyQuery
{
return $this->query ??= $this->queries->forSource(self::SOURCE);
}
private function contains(string $value): string
{
return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], trim($value)) . '%';
}
/** @param array<int,array<string,mixed>> $rows */
private function pageResult(array $rows): array
{
$total = (int) ($rows[0]['total_count'] ?? 0);
foreach ($rows as &$row) {
unset($row['total_count']);
}
return ['data' => $rows, 'total' => $total];
}
/** @param array<int,array<string,mixed>> $rows */
private function payablesPageResult(array $rows): array
{
$summaryKeys = [
'month_start_unpaid', 'goods_amount', 'prepaid_freight', 'compensation',
'supplier_refund', 'payable_amount', 'paid_amount', 'actual_payment',
'private_payment', 'corporate_payment',
];
$totals = array_fill_keys($summaryKeys, 0.0);
if ($rows !== []) {
foreach ($summaryKeys as $key) {
$totals[$key] = (float) ($rows[0]['summary_' . $key] ?? 0);
}
}
foreach ($rows as &$row) {
foreach ($summaryKeys as $key) {
unset($row['summary_' . $key]);
}
}
return array_merge($this->pageResult($rows), ['totals' => $totals]);
}
}

View File

@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Services;
use InvalidArgumentException;
use Modules\EcommerceFinance\Contracts\UpstreamSnapshotRefresher;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
@ -15,6 +16,7 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher
{
public function __construct(
private readonly JianSunQueryCache $cache,
private readonly EdgeBandingRepository $edgeBanding,
private readonly GoodsReconciliationRepository $goods,
private readonly PinduoduoSpreadBillRepository $pinduoduo,
private readonly SupplierPayableRepository $supplier,
@ -50,6 +52,10 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher
$flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : null;
return match ($scope) {
'edge-banding.prices' => $this->edgeBanding->prices($filters, $page, $limit),
'edge-banding.details' => $this->edgeBanding->details($filters, $page, $limit),
'edge-banding.payables' => $this->edgeBanding->payables($filters, $page, $limit),
'goods.list' => $this->goods->list($filters, $page, $limit),
'goods.summary' => $this->goods->summary($filters),
'goods.options' => $this->goods->filterOptions(),

View File

@ -55,6 +55,16 @@ class UpstreamSourceRegistry
return $configured;
}
if ($suffix === 'connection') {
return $this->connection($definition['key'], (array) $configured);
}
// Hosts and credentials come from the shared record, while the ODBC
// driver must match the operating system that runs this application.
if ($suffix === 'connection.odbc_driver' && trim((string) $configured) !== '') {
return $configured;
}
return $this->storedConfiguration->value($definition['key'], $suffix, $configured);
}
@ -67,4 +77,30 @@ class UpstreamSourceRegistry
{
return (bool) config('upstreams.local_configuration.enabled', false);
}
private function connection(string $source, array $configured): array
{
$fields = array_values(array_unique(array_merge(array_keys($configured), [
'host',
'port',
'database',
'username',
'password',
'odbc_driver',
'tds_version',
'connect_timeout',
'query_timeout',
])));
$resolved = $configured;
foreach ($fields as $field) {
$resolved[$field] = $this->value(
$source,
'connection.' . $field,
$configured[$field] ?? null,
);
}
return $resolved;
}
}

View File

@ -1,6 +1,8 @@
<style>
.edge-banding-page {
color: #1f2937;
color: var(--vip-content-ink, #171717);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-size: 0.8125rem;
}
.edge-banding-toolbar {
@ -10,9 +12,9 @@
gap: 0.75rem;
margin-bottom: 1rem;
padding: 1rem;
border: 1px solid #e5e7eb;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: #ffffff;
background: var(--vip-surface, #ffffff);
}
.edge-banding-field {
@ -23,46 +25,118 @@
}
.edge-banding-field label {
color: #6b7280;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
line-height: 1.333;
}
.edge-banding-field input,
.edge-banding-field select {
height: 2.25rem;
height: 2.125rem;
min-width: 9rem;
padding: 0 0.65rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #ffffff;
color: #374151;
font-size: 0.8125rem;
padding: 0 0.625rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font: inherit;
outline: none;
}
.edge-banding-field input:focus,
.edge-banding-field select:focus {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
border-color: var(--vip-content-accent, #856632);
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.edge-banding-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.edge-banding-button {
display: inline-flex;
height: 2.125rem;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0 0.875rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font: inherit;
font-weight: 500;
line-height: 1;
text-decoration: none;
cursor: pointer;
}
.edge-banding-button--primary {
border-color: var(--vip-content-accent, #856632);
background: var(--vip-content-accent, #856632);
color: #ffffff;
}
.edge-banding-button:hover {
border-color: var(--vip-content-accent, #856632);
background: var(--vip-content-accent-soft, #f7f0de);
color: var(--vip-content-ink, #171717);
}
.edge-banding-button--primary:hover {
border-color: #6a5129;
background: #6a5129;
color: #ffffff;
}
.edge-banding-button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.edge-banding-button .material-icons-outlined {
font-size: 1.125rem;
}
.edge-banding-alert {
display: flex;
align-items: flex-start;
gap: 0.625rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--vip-danger, #b80000);
border-radius: 0.5rem;
background: #fff5f5;
color: var(--vip-danger, #b80000);
}
.edge-banding-alert .material-icons-outlined {
flex: 0 0 auto;
font-size: 1.125rem;
}
.edge-banding-table-card {
overflow: hidden;
border: 1px solid #e5e7eb;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: #ffffff;
background: var(--vip-surface, #ffffff);
}
.edge-banding-table-scroll {
overflow-x: auto;
scrollbar-color: var(--vip-content-accent, #b9954f) #efede8;
scrollbar-width: thin;
}
.edge-banding-table {
width: 100%;
min-width: 68rem;
border-collapse: collapse;
font-size: 0.8125rem;
font-size: 0.78125rem;
font-variant-numeric: tabular-nums;
}
.edge-banding-table--details {
@ -71,10 +145,10 @@
.edge-banding-table th,
.edge-banding-table td {
height: 2.75rem;
padding: 0.55rem 0.7rem;
border-right: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
height: 2.5rem;
padding: 0.5rem 0.625rem;
border-right: 1px solid var(--vip-border, #d8d2c7);
border-bottom: 1px solid var(--vip-border, #d8d2c7);
text-align: left;
white-space: nowrap;
}
@ -85,13 +159,14 @@
}
.edge-banding-table thead th {
background: #f4f6fa;
color: #374151;
font-weight: 700;
background: var(--vip-content-accent-soft, #f7f0de);
color: var(--vip-content-ink, #171717);
font-size: 0.75rem;
font-weight: 600;
}
.edge-banding-table tbody tr:hover {
background: #f9fafb;
background: #fbf8f0;
}
.edge-banding-table .is-number {
@ -100,13 +175,13 @@
.edge-banding-table .is-empty td {
height: 8rem;
color: #9ca3af;
color: var(--vip-content-muted, #595959);
text-align: center;
}
.edge-banding-table--compensation thead th:last-child {
background: #fff200;
color: #111827;
background: var(--vip-content-accent-soft, #f7f0de);
color: var(--vip-content-ink, #171717);
}
.edge-banding-table--payables {
@ -115,17 +190,17 @@
.edge-banding-table--payables thead th,
.edge-banding-table--payables tfoot td {
border-color: #064b2a;
background: #08ad4f;
color: #062b19;
border-color: var(--vip-border, #d8d2c7);
background: var(--vip-content-accent-soft, #f7f0de);
color: var(--vip-content-ink, #171717);
text-align: center;
white-space: normal;
}
.edge-banding-table--payables thead tr:first-child th {
height: 3.25rem;
font-size: 1.2rem;
font-weight: 500;
height: 3rem;
font-size: 0.875rem;
font-weight: 600;
}
.edge-banding-table--payables tbody td {
@ -137,7 +212,7 @@
}
.edge-banding-table--payables tfoot td {
font-weight: 700;
font-weight: 600;
text-align: right;
}
@ -148,12 +223,29 @@
.edge-banding-pagination {
display: flex;
min-height: 3rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
color: #6b7280;
font-size: 0.8125rem;
padding: 0.625rem 1rem;
color: var(--vip-content-muted, #595959);
font-size: 0.78125rem;
}
.edge-banding-pagination__controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.edge-banding-pagination__controls .edge-banding-button {
width: 2.125rem;
padding: 0;
}
.edge-banding-pagination__controls .is-disabled {
pointer-events: none;
opacity: 0.45;
}
@media (max-width: 640px) {
@ -167,6 +259,10 @@
width: 100%;
}
.edge-banding-actions {
width: 100%;
}
.edge-banding-pagination {
align-items: flex-start;
flex-direction: column;

View File

@ -1,40 +1,69 @@
@php
$edgeData = $edgeBanding ?? [];
$result = $edgeData['result'] ?? ['data' => [], 'total' => 0];
$rows = $result['data'] ?? [];
$filters = $edgeData['filters'] ?? [];
$total = (int) ($result['total'] ?? 0);
$currentPage = (int) ($edgeData['page'] ?? 1);
$lastPage = (int) ($edgeData['last_page'] ?? 1);
$error = $edgeData['error'] ?? null;
$number = static fn ($value) => $value === null || $value === '' ? '-' : number_format((float) $value, 2);
@endphp
<div class="edge-banding-page">
@include('business.pages.edge-banding-materials._styles')
<div class="edge-banding-toolbar" aria-label="封边条明细筛选">
@if ($error)
<div class="edge-banding-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $error }}</span>
</div>
@endif
<form class="edge-banding-toolbar" method="get" action="{{ request()->url() }}" aria-label="封边条明细筛选">
<div class="edge-banding-field">
<label for="edge-details-in-date">入库日期</label>
<input id="edge-details-in-date" type="date">
<label for="edge-details-date">入库日期</label>
<input id="edge-details-date" name="date" type="date" value="{{ $filters['date'] ?? '' }}">
</div>
<div class="edge-banding-field">
<label for="edge-details-production-code">原料生产编号</label>
<input id="edge-details-production-code" type="text" placeholder="请输入生产编号">
<input id="edge-details-production-code" name="production_code" type="text" value="{{ $filters['production_code'] ?? '' }}" placeholder="请输入生产编号">
</div>
<div class="edge-banding-field">
<label for="edge-details-raw-name">原料名称</label>
<input id="edge-details-raw-name" type="text" placeholder="请输入原料名称">
<input id="edge-details-raw-name" name="raw_name" type="text" value="{{ $filters['raw_name'] ?? '' }}" placeholder="请输入原料名称">
</div>
<div class="edge-banding-field">
<label for="edge-details-return-flag">退货标识</label>
<select id="edge-details-return-flag">
<option>全部</option>
<option>正常出库</option>
<option>退货</option>
<select id="edge-details-return-flag" name="return_flag">
<option value="">全部</option>
<option value="normal" @selected(($filters['return_flag'] ?? '') === 'normal')>正常出库</option>
<option value="return" @selected(($filters['return_flag'] ?? '') === 'return')>退货</option>
</select>
</div>
<div class="edge-banding-field">
<label for="edge-details-out-factory">出库厂商</label>
<input id="edge-details-out-factory" type="text" placeholder="请输入出库厂商">
<input id="edge-details-out-factory" name="outbound_supplier" type="text" value="{{ $filters['outbound_supplier'] ?? '' }}" placeholder="请输入出库厂商">
</div>
<div class="edge-banding-field">
<label for="edge-details-arrival-factory">到货厂商</label>
<input id="edge-details-arrival-factory" type="text" placeholder="请输入到货厂商">
<input id="edge-details-arrival-factory" name="arrival_supplier" type="text" value="{{ $filters['arrival_supplier'] ?? '' }}" placeholder="请输入到货厂商">
</div>
<div class="edge-banding-field">
<label for="edge-details-out-number">出库单号</label>
<input id="edge-details-out-number" type="text" placeholder="请输入出库单号">
<input id="edge-details-out-number" name="outbound_no" type="text" value="{{ $filters['outbound_no'] ?? '' }}" placeholder="请输入出库单号或送货号">
</div>
</div>
<div class="edge-banding-actions">
<button class="edge-banding-button edge-banding-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a class="edge-banding-button" href="{{ request()->url() }}" title="清除筛选条件">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
</div>
</form>
<div class="edge-banding-table-card">
<div class="edge-banding-table-scroll">
@ -67,15 +96,55 @@
</tr>
</thead>
<tbody>
<tr class="is-empty">
<td colspan="23">暂无数据</td>
</tr>
@forelse ($rows as $row)
<tr>
<td>{{ $row['inbound_date'] ?: '-' }}</td>
<td>{{ $row['production_code'] ?: '-' }}</td>
<td>{{ $row['raw_name'] ?: '-' }}</td>
<td class="is-number">{{ $number($row['outbound_quantity'] ?? null) }}</td>
<td>{{ $row['unit'] ?: '-' }}</td>
<td>{{ $row['return_flag'] ?: '-' }}</td>
<td>{{ $row['outbound_supplier'] ?: '-' }}</td>
<td>{{ $row['arrival_supplier'] ?: '-' }}</td>
<td>{{ $row['batch_date'] ?: '-' }}</td>
<td>{{ $row['outbound_no'] ?: '-' }}</td>
<td>{{ $row['outbound_remark'] ?: '-' }}</td>
<td>{{ $row['requisition_no'] ?: '-' }}</td>
<td>{{ $row['number_type'] ?: '-' }}</td>
<td>{{ $row['delivery_no'] ?: '-' }}</td>
<td class="is-number">{{ $number($row['received_quantity'] ?? null) }}</td>
<td class="is-number">{{ $number($row['reconciliation_amount'] ?? null) }}</td>
<td class="is-number">{{ $number($row['freight'] ?? null) }}</td>
<td class="is-number">{{ $number($row['edge_band_unit_price'] ?? null) }}</td>
<td class="is-number">{{ $number($row['payable_amount'] ?? null) }}</td>
<td class="is-number">{{ $number($row['furniture_unit_price'] ?? null) }}</td>
<td class="is-number">{{ $number($row['furniture_amount'] ?? null) }}</td>
<td class="is-number">{{ $number($row['price_difference'] ?? null) }}</td>
<td class="is-number">{{ $number($row['profit'] ?? null) }}</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="23">{{ $error ? '数据源暂不可用' : '当前筛选条件下没有匹配的封边条明细' }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="edge-banding-pagination">
<span>共 0 条记录</span>
<span>第 1 / 1 页</span>
<span>共 {{ number_format($total) }} 条记录</span>
<div class="edge-banding-pagination__controls" aria-label="明细数据分页">
@if ($currentPage <= 1)
<span class="edge-banding-button is-disabled" aria-disabled="true" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></span>
@else
<a class="edge-banding-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage - 1]) }}" aria-label="上一页" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
@endif
<span>第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页</span>
@if ($currentPage >= $lastPage)
<span class="edge-banding-button is-disabled" aria-disabled="true" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></span>
@else
<a class="edge-banding-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage + 1]) }}" aria-label="下一页" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
@endif
</div>
</div>
</div>
</div>

View File

@ -1,23 +1,58 @@
@php
$edgeData = $edgeBanding ?? [];
$result = $edgeData['result'] ?? ['data' => [], 'total' => 0, 'totals' => []];
$rows = $result['data'] ?? [];
$totals = $result['totals'] ?? [];
$filters = $edgeData['filters'] ?? [];
$period = $filters['period'] ?? '2026-06';
[$periodYear, $periodMonth] = array_pad(explode('-', $period, 2), 2, '');
$periodTitle = ctype_digit($periodYear) && ctype_digit($periodMonth)
? $periodYear . '年公司应付封边条厂' . (int) $periodMonth . '月份货款'
: '公司应付封边条厂货款';
$total = (int) ($result['total'] ?? 0);
$currentPage = (int) ($edgeData['page'] ?? 1);
$lastPage = (int) ($edgeData['last_page'] ?? 1);
$error = $edgeData['error'] ?? null;
$money = static fn ($value) => $value === null || $value === '' ? '-' : number_format((float) $value, 2);
@endphp
<div class="edge-banding-page">
@include('business.pages.edge-banding-materials._styles')
<div class="edge-banding-toolbar" aria-label="封边条应付筛选">
@if ($error)
<div class="edge-banding-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $error }}</span>
</div>
@endif
<form class="edge-banding-toolbar" method="get" action="{{ request()->url() }}" aria-label="封边条应付筛选">
<div class="edge-banding-field">
<label for="edge-payables-period">应付月份</label>
<input id="edge-payables-period" type="month" value="2026-06">
<input id="edge-payables-period" name="period" type="month" value="{{ $period }}">
</div>
<div class="edge-banding-field">
<label for="edge-payables-supplier">供货单位</label>
<input id="edge-payables-supplier" type="text" placeholder="请输入供货单位">
<input id="edge-payables-supplier" name="supplier" type="text" value="{{ $filters['supplier'] ?? '' }}" placeholder="请输入供货单位">
</div>
</div>
<div class="edge-banding-actions">
<button class="edge-banding-button edge-banding-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a class="edge-banding-button" href="{{ request()->url() }}" title="恢复默认月份并清除筛选条件">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
</div>
</form>
<div class="edge-banding-table-card">
<div class="edge-banding-table-scroll">
<table class="edge-banding-table edge-banding-table--payables">
<thead>
<tr>
<th colspan="13">2026年公司应付封边条厂6月份货款</th>
<th colspan="13">{{ $periodTitle }}</th>
</tr>
<tr>
<th rowspan="2">序号</th>
@ -47,32 +82,62 @@
</tr>
</thead>
<tbody>
<tr class="is-empty">
<td colspan="13">暂无数据</td>
</tr>
@forelse ($rows as $index => $row)
<tr>
<td>{{ (($currentPage - 1) * ($edgeData['limit'] ?? 25)) + $index + 1 }}</td>
<td>{{ $row['supplier'] ?: '-' }}</td>
<td>{{ $money($row['month_start_unpaid'] ?? null) }}</td>
<td>{{ $money($row['goods_amount'] ?? null) }}</td>
<td>{{ $money($row['prepaid_freight'] ?? null) }}</td>
<td>{{ $money($row['compensation'] ?? null) }}</td>
<td>{{ $money($row['supplier_refund'] ?? null) }}</td>
<td>{{ $money($row['payable_amount'] ?? null) }}</td>
<td>{{ $money($row['paid_amount'] ?? null) }}</td>
<td>{{ $money($row['actual_payment'] ?? null) }}</td>
<td>{{ $money($row['private_payment'] ?? null) }}</td>
<td>{{ $money($row['corporate_payment'] ?? null) }}</td>
<td>{{ $row['remark'] ?: '-' }}</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="13">{{ $error ? '数据源暂不可用' : '当前月份没有匹配的封边条应付数据' }}</td>
</tr>
@endforelse
</tbody>
<tfoot>
<tr>
<td>合计</td>
<td></td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>全部对公</td>
<td>{{ $money($totals['month_start_unpaid'] ?? null) }}</td>
<td>{{ $money($totals['goods_amount'] ?? null) }}</td>
<td>{{ $money($totals['prepaid_freight'] ?? null) }}</td>
<td>{{ $money($totals['compensation'] ?? null) }}</td>
<td>{{ $money($totals['supplier_refund'] ?? null) }}</td>
<td>{{ $money($totals['payable_amount'] ?? null) }}</td>
<td>{{ $money($totals['paid_amount'] ?? null) }}</td>
<td>{{ $money($totals['actual_payment'] ?? null) }}</td>
<td>{{ $money($totals['private_payment'] ?? null) }}</td>
<td>{{ $money($totals['corporate_payment'] ?? null) }}</td>
<td>{{ (float) ($totals['private_payment'] ?? 0) === 0.0 ? '全部对公' : '-' }}</td>
</tr>
</tfoot>
</table>
</div>
<div class="edge-banding-pagination">
<span>共 0 条记录</span>
<span>第 1 / 1 页</span>
<span>共 {{ number_format($total) }} 条记录</span>
<div class="edge-banding-pagination__controls" aria-label="应付数据分页">
@if ($currentPage <= 1)
<span class="edge-banding-button is-disabled" aria-disabled="true" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></span>
@else
<a class="edge-banding-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage - 1]) }}" aria-label="上一页" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
@endif
<span>第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页</span>
@if ($currentPage >= $lastPage)
<span class="edge-banding-button is-disabled" aria-disabled="true" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></span>
@else
<a class="edge-banding-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage + 1]) }}" aria-label="下一页" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
@endif
</div>
</div>
</div>
</div>

View File

@ -1,40 +1,52 @@
@php
$edgeData = $edgeBanding ?? [];
$result = $edgeData['result'] ?? ['data' => [], 'total' => 0];
$rows = $result['data'] ?? [];
$filters = $edgeData['filters'] ?? [];
$total = (int) ($result['total'] ?? 0);
$currentPage = (int) ($edgeData['page'] ?? 1);
$lastPage = (int) ($edgeData['last_page'] ?? 1);
$error = $edgeData['error'] ?? null;
@endphp
<div class="edge-banding-page">
@include('business.pages.edge-banding-materials._styles')
<div class="edge-banding-toolbar" aria-label="封边条价格筛选">
<div class="edge-banding-field">
<label for="edge-price-type">价格类型</label>
<select id="edge-price-type">
<option>全部价格</option>
<option>封边条厂价格</option>
<option>家具厂价格</option>
</select>
@if ($error)
<div class="edge-banding-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $error }}</span>
</div>
@endif
<form class="edge-banding-toolbar" method="get" action="{{ request()->url() }}" aria-label="封边条价格筛选">
<div class="edge-banding-field">
<label for="edge-price-raw-name">原料名称</label>
<input id="edge-price-raw-name" type="text" placeholder="请输入原料名称">
<input id="edge-price-raw-name" name="raw_name" type="text" value="{{ $filters['raw_name'] ?? '' }}" placeholder="请输入原料名称">
</div>
<div class="edge-banding-field">
<label for="edge-price-raw-code">原料编号</label>
<input id="edge-price-raw-code" type="text" placeholder="请输入原料编号">
<input id="edge-price-raw-code" name="raw_code" type="text" value="{{ $filters['raw_code'] ?? '' }}" placeholder="请输入原料编号">
</div>
<div class="edge-banding-field">
<label for="edge-price-production-code">生产编号</label>
<input id="edge-price-production-code" type="text" placeholder="请输入生产编号">
<input id="edge-price-production-code" name="production_code" type="text" value="{{ $filters['production_code'] ?? '' }}" placeholder="请输入生产编号">
</div>
<div class="edge-banding-field">
<label for="edge-price-target-factory">目标厂商</label>
<input id="edge-price-target-factory" type="text" placeholder="请输入目标厂商">
<label for="edge-price-supplier">目标厂商</label>
<input id="edge-price-supplier" name="supplier" type="text" value="{{ $filters['supplier'] ?? '' }}" placeholder="请输入目标厂商">
</div>
<div class="edge-banding-field">
<label for="edge-price-factory-type">厂商类型</label>
<select id="edge-price-factory-type">
<option>全部厂商</option>
<option>封边条厂</option>
<option>家具厂</option>
</select>
<div class="edge-banding-actions">
<button class="edge-banding-button edge-banding-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a class="edge-banding-button" href="{{ request()->url() }}" title="清除筛选条件">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
</div>
</div>
</form>
<div class="edge-banding-table-card">
<div class="edge-banding-table-scroll">
@ -51,15 +63,39 @@
</tr>
</thead>
<tbody>
<tr class="is-empty">
<td colspan="7">暂无数据</td>
</tr>
@forelse ($rows as $row)
<tr>
<td>{{ $row['price_type'] ?: '-' }}</td>
<td>{{ $row['raw_name'] ?: '-' }}</td>
<td>{{ $row['raw_code'] ?: '-' }}</td>
<td>{{ $row['production_code'] ?: '-' }}</td>
<td class="is-number">{{ $row['latest_price'] === null ? '-' : number_format((float) $row['latest_price'], 2) }}</td>
<td>{{ $row['target_supplier'] ?: '-' }}</td>
<td>{{ $row['factory_type'] ?: '-' }}</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="7">{{ $error ? '数据源暂不可用' : '当前筛选条件下没有匹配的封边条价格' }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="edge-banding-pagination">
<span>共 0 条记录</span>
<span>第 1 / 1 页</span>
<span>共 {{ number_format($total) }} 条记录</span>
<div class="edge-banding-pagination__controls" aria-label="价格数据分页">
@if ($currentPage <= 1)
<span class="edge-banding-button is-disabled" aria-disabled="true" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></span>
@else
<a class="edge-banding-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage - 1]) }}" aria-label="上一页" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
@endif
<span>第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页</span>
@if ($currentPage >= $lastPage)
<span class="edge-banding-button is-disabled" aria-disabled="true" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></span>
@else
<a class="edge-banding-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage + 1]) }}" aria-label="下一页" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
@endif
</div>
</div>
</div>
</div>

View File

@ -82,7 +82,7 @@ class PagesTest extends FeatureTestCase
];
foreach ($pages as $page => $expectedHeader) {
$this->loginAs()
$response = $this->loginAs()
->get(route('business.pages.show', [
'section' => 'edge-banding-materials',
'page' => $page,
@ -91,6 +91,10 @@ class PagesTest extends FeatureTestCase
->assertSee($expectedHeader)
->assertSee('class="edge-banding-table', false)
->assertDontSee('class="business-blank-page"', false);
if ($page !== 'compensation') {
$response->assertSeeText('该上游数据同步尚未启用,请联系管理员。');
}
}
}

View File

@ -186,7 +186,7 @@ class UpstreamDatabasesTest extends FeatureTestCase
}
}
public function test_whole_connection_comes_from_shared_system_database(): void
public function test_complete_connection_uses_shared_fields_and_local_runtime_options(): void
{
$connection = $this->connection('shared-secret');
$connection->update([
@ -195,6 +195,7 @@ class UpstreamDatabasesTest extends FeatureTestCase
'port' => 14330,
'database_name' => 'shared_database',
'username' => 'shared_reader',
'odbc_driver' => 'Stored Driver',
]);
config([
'upstreams.local_configuration.enabled' => false,
@ -209,7 +210,7 @@ class UpstreamDatabasesTest extends FeatureTestCase
'encryption' => 'off',
'connect_timeout' => 5,
'query_timeout' => 15,
'application_intent' => '',
'application_intent' => 'ReadOnly',
],
]);
@ -221,7 +222,9 @@ class UpstreamDatabasesTest extends FeatureTestCase
$this->assertSame('shared_database', $resolved['database']);
$this->assertSame('shared_reader', $resolved['username']);
$this->assertSame('shared-secret', $resolved['password']);
$this->assertSame('FreeTDS', $resolved['odbc_driver']);
$this->assertSame('off', $resolved['encryption']);
$this->assertSame('ReadOnly', $resolved['application_intent']);
}
public function test_global_wildcard_scope_is_rejected(): void

View File

@ -0,0 +1,97 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\TestCase;
class EdgeBandingRepositoryTest extends TestCase
{
public function test_price_rows_are_normalized_and_paginated(): void
{
$query = $this->createMock(UpstreamReadOnlyQuery::class);
$query->expects($this->once())->method('select')->willReturnCallback(
function (string $sql): array {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('[dbo].[pm_averageUnitPrice]', $sql);
return [[
'raw_code' => 'FBT442',
'latest_price' => '0.10',
'total_count' => 1,
]];
}
);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query);
$result = (new EdgeBandingRepository($factory))->prices([], 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('FBT442', $result['data'][0]['raw_code']);
$this->assertSame('采购价', $result['data'][0]['price_type']);
$this->assertSame('封边条厂', $result['data'][0]['factory_type']);
$this->assertArrayNotHasKey('total_count', $result['data'][0]);
}
public function test_detail_query_uses_the_read_only_guard(): void
{
$query = $this->createMock(UpstreamReadOnlyQuery::class);
$query->expects($this->once())->method('select')->willReturnCallback(
function (string $sql): array {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('[dbo].[fys_supplierGoods_sttlement_sale]', $sql);
return [[
'production_code' => 'FBT440',
'outbound_quantity' => '14000.00',
'total_count' => 1,
]];
}
);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query);
$result = (new EdgeBandingRepository($factory))->details([], 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('FBT440', $result['data'][0]['production_code']);
$this->assertSame('米', $result['data'][0]['unit']);
$this->assertSame('正常出库', $result['data'][0]['return_flag']);
$this->assertSame('申领', $result['data'][0]['number_type']);
}
public function test_payable_rows_expose_full_result_totals(): void
{
$query = $this->createMock(UpstreamReadOnlyQuery::class);
$query->expects($this->once())->method('select')->willReturnCallback(
function (string $sql): array {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('[dbo].[fys_supplierPaymentSummary]', $sql);
return [[
'supplier' => '封边条厂',
'payable_amount' => '100.00',
'summary_payable_amount' => '100.00',
'summary_corporate_payment' => '100.00',
'summary_private_payment' => '0.00',
'total_count' => 1,
]];
}
);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query);
$result = (new EdgeBandingRepository($factory))->payables(['period' => '2026-06'], 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame(100.0, $result['totals']['payable_amount']);
$this->assertSame(100.0, $result['totals']['corporate_payment']);
$this->assertArrayNotHasKey('summary_payable_amount', $result['data'][0]);
}
}

View File

@ -3,6 +3,7 @@
namespace Tests\Unit\EcommerceFinance;
use Closure;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
@ -51,9 +52,10 @@ class JianSunSnapshotRefresherTest extends TestCase
}
};
$edgeBanding = (new ReflectionClass(EdgeBandingRepository::class))->newInstanceWithoutConstructor();
$goods = (new ReflectionClass(GoodsReconciliationRepository::class))->newInstanceWithoutConstructor();
$supplier = (new ReflectionClass(SupplierPayableRepository::class))->newInstanceWithoutConstructor();
$refresher = new JianSunSnapshotRefresher($cache, $goods, $pinduoduo, $supplier);
$refresher = new JianSunSnapshotRefresher($cache, $edgeBanding, $goods, $pinduoduo, $supplier);
$status = $refresher->refresh([
'scope' => 'pinduoduo-bills.list',