修复:商品价格切换经营分析副本库 #49

Merged
WuYongJie merged 1 commits from fix/WuYongJie-product-prices-operations-replica into main 2026-08-14 13:33:54 +08:00
9 changed files with 243 additions and 177 deletions

View File

@ -497,18 +497,14 @@ class Pages extends Controller
protected function contentForProductPrices(): array
{
$priceType = trim((string) request('price_type', ''));
$costReduction = trim((string) request('cost_reduction', ''));
$filters = [
'barcode' => mb_substr(trim((string) request('barcode', '')), 0, 191),
'price_type' => in_array($priceType, ['pricing', 'repricing'], true) ? $priceType : '',
'cost_reduction' => in_array($costReduction, ['yes', 'no'], true) ? $costReduction : '',
'effective_from' => $this->dateFilter(request('effective_from')),
'effective_to' => $this->dateFilter(request('effective_to')),
'completed_from' => $this->dateFilter(request('completed_from')),
'completed_to' => $this->dateFilter(request('completed_to')),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource('jiansun');
$cache = $this->upstreamCaches->forSource('operations_replica');
try {
$result = $cache->remember(

View File

@ -6,22 +6,13 @@ use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/**
* Supplier product pricing and repricing records from JianSun.
* Completed product price-limit approvals from the operations replica.
*/
class ProductPriceRepository
{
private const SOURCE = 'jiansun';
private const TABLE = '[dbo].[pm_priceAdjustment_log]';
private const SOURCE = 'operations_replica';
private const PRICE_TYPES = [
'pricing' => '0',
'repricing' => '1',
];
private const COST_REDUCTION = [
'yes' => '1',
'no' => '0',
];
private const TABLE = '[rpt].[Bulk_Price_Limit]';
private readonly UpstreamReadOnlyQuery $query;
@ -39,54 +30,41 @@ class ProductPriceRepository
$bindings = [];
if (($filters['barcode'] ?? '') !== '') {
$where[] = "[skucode] LIKE ? ESCAPE '\\'";
$where[] = "[product_code] LIKE ? ESCAPE '\\'";
$bindings[] = $this->contains((string) $filters['barcode']);
}
$priceType = self::PRICE_TYPES[(string) ($filters['price_type'] ?? '')] ?? null;
if ($priceType !== null) {
$where[] = '[priceType] = ?';
$bindings[] = $priceType;
if (($filters['completed_from'] ?? '') !== '') {
$where[] = '[process_end_date] >= CAST(? AS date)';
$bindings[] = (string) $filters['completed_from'];
}
$costReduction = self::COST_REDUCTION[(string) ($filters['cost_reduction'] ?? '')] ?? null;
if ($costReduction !== null) {
$where[] = '[isCostReduction] = ?';
$bindings[] = $costReduction;
}
if (($filters['effective_from'] ?? '') !== '') {
$where[] = '[effectStartTime] >= CAST(? AS date)';
$bindings[] = (string) $filters['effective_from'];
}
if (($filters['effective_to'] ?? '') !== '') {
$where[] = '[effectStartTime] < DATEADD(day, 1, CAST(? AS date))';
$bindings[] = (string) $filters['effective_to'];
if (($filters['completed_to'] ?? '') !== '') {
$where[] = '[process_end_date] < DATEADD(day, 1, CAST(? AS date))';
$bindings[] = (string) $filters['completed_to'];
}
$whereSql = $where === [] ? '' : ' WHERE ' . implode(' AND ', $where);
$offset = ($page - 1) * $limit;
$fetch = $limit + 1;
$sql = 'SELECT '
. '[skucode] AS barcode, '
. "CASE [priceType] WHEN '0' THEN N'定价' WHEN '1' THEN N'变价' ELSE NULLIF([priceType], '') END AS price_type, "
. "CASE [priceChangeType] WHEN '0' THEN N'普通变价' WHEN '1' THEN N'清货变价' "
. "WHEN '2' THEN N'阶段变价' WHEN '4' THEN N'打折入库' ELSE NULLIF([priceChangeType], '') END AS change_type, "
. "CASE [isCostReduction] WHEN '1' THEN N'是' WHEN '0' THEN N'否' ELSE NULL END AS cost_reduction, "
. '[originalPrice] AS price_before, '
. '[afterPrice] AS price_after, '
. '[diffPrice] AS price_diff, '
. '[applyUser] AS applicant, '
. '[remark] AS remark, '
. 'CONVERT(varchar(19), [effectStartTime], 120) AS effective_start, '
. 'CONVERT(varchar(19), [effectEndTime], 120) AS effective_end, '
. 'CONVERT(varchar(19), COALESCE([updateTime], [createTime]), 120) AS completed_at '
. '[product_code] AS barcode, '
. '[spu] AS spu, '
. '[product_name] AS product_name, '
. '[category] AS category, '
. '[production_status] AS production_status, '
. '[min_price] AS minimum_price, '
. '[price_limit] AS price_limit, '
. '[purchase_price] AS purchase_price, '
. '[shipping_fee] AS shipping_fee, '
. '[jingxi_limit_price] AS jingxi_limit_price, '
. "CASE [status] WHEN 'COMPLETED' THEN N'已完成' ELSE NULLIF([status], '') END AS approval_status, "
. "CASE [result] WHEN 'agree' THEN N'已同意' WHEN 'refuse' THEN N'已拒绝' ELSE NULLIF([result], '') END AS approval_result, "
. '[title] AS workflow_title, '
. 'CONVERT(varchar(19), [process_end_date], 120) AS completed_at '
. 'FROM ' . self::TABLE
. $whereSql
. ' ORDER BY COALESCE([updateTime], [createTime]) DESC, [Id] DESC, [skucode] ASC'
. ' ORDER BY [process_end_date] DESC, [businessId] DESC, [product_code] ASC'
. " OFFSET {$offset} ROWS FETCH NEXT {$fetch} ROWS ONLY";
return ['rows' => array_map([$this, 'normalize'], $this->query->select($sql, $bindings))];
@ -100,7 +78,7 @@ class ProductPriceRepository
/** @param array<string,mixed> $row */
private function normalize(array $row): array
{
foreach (['price_before', 'price_after', 'price_diff'] as $field) {
foreach (['minimum_price', 'price_limit', 'purchase_price', 'shipping_fee', 'jingxi_limit_price'] as $field) {
$row[$field] = $row[$field] === null || $row[$field] === ''
? null
: (float) $row[$field];

View File

@ -10,7 +10,6 @@ use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
@ -28,7 +27,6 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher
private readonly InventoryRepository $inventory,
private readonly AlibabaBillRepository $alibaba,
private readonly PinduoduoSpreadBillRepository $pinduoduo,
private readonly ProductPriceRepository $productPrices,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly TmallTaobaoSpreadBillRepository $taobaoAlipay,
private readonly SupplierPayableRepository $supplier,
@ -162,11 +160,6 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher
),
'taobao-alipay-bills.options' => $this->taobaoAlipay->storeOptions(),
'supplier-payables.product-prices.list' => $this->productPrices->paginate(
$filters,
$page,
$limit,
),
'supplier-payables.board-deductions.list' => $this->supplierPayableReports->boardDeductions(
$filters,
$page,

View File

@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Services;
use App\Business\UpstreamBusinessInformation;
use Modules\EcommerceFinance\Contracts\UpstreamSnapshotRefresher;
use Modules\EcommerceFinance\Repositories\OrderDataRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceRepository;
/**
* 将经营分析副本库已登记的业务信息查询还原为共享快照刷新任务。
@ -17,6 +18,7 @@ class OperationsReplicaSnapshotRefresher implements UpstreamSnapshotRefresher
private readonly UpstreamQueryCacheFactory $caches,
private readonly UpstreamBusinessInformation $information,
private readonly OrderDataRepository $orders,
private readonly ProductPriceRepository $productPrices,
) {
}
@ -44,6 +46,11 @@ class OperationsReplicaSnapshotRefresher implements UpstreamSnapshotRefresher
(int) ($parameters['page'] ?? 1),
(int) ($parameters['limit'] ?? 25),
),
'supplier-payables.product-prices.list' => $this->productPrices->paginate(
(array) ($parameters['filters'] ?? []),
(int) ($parameters['page'] ?? 1),
(int) ($parameters['limit'] ?? 25),
),
default => $this->information->snapshot($scope, $parameters),
},
);

View File

@ -7,7 +7,7 @@
<style>
.product-prices-filter-bar {
display: grid;
grid-template-columns: minmax(180px, 1.4fr) repeat(4, minmax(140px, 1fr)) auto;
grid-template-columns: minmax(220px, 1.4fr) repeat(2, minmax(160px, 1fr)) auto;
align-items: flex-end;
gap: 16px;
padding: 16px;
@ -87,40 +87,27 @@
border-color: var(--vip-content-accent-dark, #856632);
}
.price-tag {
.approval-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.5;
}
.price-tag.dingjia,
.price-tag.bianjia {
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
}
.price-tag.bianjia { box-shadow: inset 0 0 0 1px var(--vip-content-accent, #B9954F); }
.price-diff-positive {
.approval-tag.is-approved {
background: rgba(83, 121, 62, 0.10);
color: var(--vip-success, #53793E);
font-weight: 600;
}
.price-diff-negative {
.approval-tag.is-rejected {
background: rgba(184, 0, 0, 0.08);
color: var(--vip-danger, #B80000);
font-weight: 600;
}
.price-diff-zero {
color: #595959;
}
.cost-reduction-icon.is-yes { color: var(--vip-success, #53793E); }
.cost-reduction-icon.is-no { color: var(--vip-danger, #B80000); }
.product-price-error {
display: flex;
align-items: center;
@ -144,7 +131,7 @@
.data-table {
width: 100%;
min-width: 1380px;
min-width: 1540px;
border-collapse: collapse;
font-size: 0.78125rem;
}
@ -197,7 +184,7 @@
display: flex;
align-items: center;
justify-content: space-between;
min-width: 1380px;
min-width: 1540px;
padding: 10px 12px;
background: #ffffff;
border-top: 1px solid var(--vip-border, #D8D2C7);
@ -210,7 +197,7 @@
}
.pagination-wrapper {
min-width: 1380px;
min-width: 1540px;
padding: 12px;
display: flex;
justify-content: center;
@ -266,28 +253,12 @@
value="{{ $productPriceFilters['barcode'] ?? '' }}">
</div>
<div class="filter-group">
<label class="filter-label">价格类型</label>
<select name="price_type">
<option value="">全部</option>
<option value="pricing" {{ ($productPriceFilters['price_type'] ?? '') === 'pricing' ? 'selected' : '' }}>定价</option>
<option value="repricing" {{ ($productPriceFilters['price_type'] ?? '') === 'repricing' ? 'selected' : '' }}>变价</option>
</select>
<label class="filter-label">流程完成日期起</label>
<input type="date" name="completed_from" value="{{ $productPriceFilters['completed_from'] ?? '' }}">
</div>
<div class="filter-group">
<label class="filter-label">是否采购降本</label>
<select name="cost_reduction">
<option value="">全部</option>
<option value="yes" {{ ($productPriceFilters['cost_reduction'] ?? '') === 'yes' ? 'selected' : '' }}>是</option>
<option value="no" {{ ($productPriceFilters['cost_reduction'] ?? '') === 'no' ? 'selected' : '' }}>否</option>
</select>
</div>
<div class="filter-group">
<label class="filter-label">生效日期起</label>
<input type="date" name="effective_from" value="{{ $productPriceFilters['effective_from'] ?? '' }}">
</div>
<div class="filter-group">
<label class="filter-label">生效日期止</label>
<input type="date" name="effective_to" value="{{ $productPriceFilters['effective_to'] ?? '' }}">
<label class="filter-label">流程完成日期止</label>
<input type="date" name="completed_to" value="{{ $productPriceFilters['completed_to'] ?? '' }}">
</div>
<div class="filter-group">
<label class="filter-label">&nbsp;</label>
@ -318,74 +289,56 @@
<thead>
<tr>
<th>#</th>
<th>条形码</th>
<th>价格类型</th>
<th>变价类型</th>
<th>是否采购降本</th>
<th class="right">变价前价格</th>
<th class="right">变价后价格</th>
<th class="right">差价</th>
<th>申请人</th>
<th>备注</th>
<th>生效开始时间</th>
<th>生效结束时间</th>
<th>商品编码</th>
<th>SPU</th>
<th>商品名称</th>
<th>类目</th>
<th>生产状态</th>
<th class="right">最低售价</th>
<th class="right">计算限价</th>
<th class="right">采购价</th>
<th class="right">运费</th>
<th class="right">京喜限价</th>
<th>流程状态</th>
<th>审批结果</th>
<th>流程标题</th>
<th>流程完成时间</th>
</tr>
</thead>
<tbody>
@forelse ($productPrices ?? [] as $index => $item)
@php
$priceTypeLabel = $item->price_type ?: '-';
@endphp
<tr>
<td class="mono">{{ ($productPrices->currentPage() - 1) * $productPrices->perPage() + $index + 1 }}</td>
<td class="mono">{{ $item->barcode ?? '-' }}</td>
<td class="mono">{{ $item->spu ?? '-' }}</td>
<td>{{ $item->product_name ?? '-' }}</td>
<td>{{ $item->category ?? '-' }}</td>
<td>{{ $item->production_status ?? '-' }}</td>
<td class="right">{{ isset($item->minimum_price) && $item->minimum_price !== null ? number_format((float) $item->minimum_price, 2) : '-' }}</td>
<td class="right">{{ isset($item->price_limit) && $item->price_limit !== null ? number_format((float) $item->price_limit, 2) : '-' }}</td>
<td class="right">{{ isset($item->purchase_price) && $item->purchase_price !== null ? number_format((float) $item->purchase_price, 2) : '-' }}</td>
<td class="right">{{ isset($item->shipping_fee) && $item->shipping_fee !== null ? number_format((float) $item->shipping_fee, 2) : '-' }}</td>
<td class="right">{{ isset($item->jingxi_limit_price) && $item->jingxi_limit_price !== null ? number_format((float) $item->jingxi_limit_price, 2) : '-' }}</td>
<td><span class="approval-tag">{{ $item->approval_status ?? '-' }}</span></td>
<td>
<span class="price-tag {{ $priceTypeLabel === '变价' ? 'bianjia' : 'dingjia' }}">
{{ $priceTypeLabel }}
<span class="approval-tag {{ ($item->approval_result ?? '') === '已同意' ? 'is-approved' : (($item->approval_result ?? '') === '已拒绝' ? 'is-rejected' : '') }}">
{{ $item->approval_result ?? '-' }}
</span>
</td>
<td>{{ $item->change_type ?? '-' }}</td>
<td>
@if (($item->cost_reduction ?? '') === '是')
<span class="material-icons-outlined cost-reduction-icon is-yes" title="是">check_circle</span>
@elseif (($item->cost_reduction ?? '') === '否')
<span class="material-icons-outlined cost-reduction-icon is-no" title="否">cancel</span>
@else
<span>-</span>
@endif
</td>
<td class="right">{{ isset($item->price_before) && $item->price_before !== null ? number_format((float) $item->price_before, 2) : '-' }}</td>
<td class="right">{{ isset($item->price_after) && $item->price_after !== null ? number_format((float) $item->price_after, 2) : '-' }}</td>
<td class="right">
@php
$diff = $item->price_diff ?? null;
@endphp
@if (isset($diff))
<span class="{{ $diff > 0 ? 'price-diff-positive' : ($diff < 0 ? 'price-diff-negative' : 'price-diff-zero') }}">
{{ $diff > 0 ? '+' : '' }}{{ number_format((float) $diff, 2) }}
@if (!empty($item->workflow_title))
<span title="{{ $item->workflow_title }}">
{{ mb_strlen($item->workflow_title) > 20 ? mb_substr($item->workflow_title, 0, 20) . '...' : $item->workflow_title }}
</span>
@else
-
@endif
</td>
<td>{{ $item->applicant ?? '-' }}</td>
<td>
@if (!empty($item->remark))
<span title="{{ $item->remark }}">
{{ mb_strlen($item->remark) > 12 ? mb_substr($item->remark, 0, 12) . '...' : $item->remark }}
</span>
@else
-
@endif
</td>
<td>{{ $item->effective_start ?? '-' }}</td>
<td>{{ $item->effective_end ?? '-' }}</td>
<td>{{ $item->completed_at ?? '-' }}</td>
</tr>
@empty
<tr>
<td colspan="13" class="text-center py-12">
<td colspan="15" class="text-center py-12">
<span class="material-icons-outlined text-4xl mb-2 block">inventory_2</span>
@if ($productPriceError)
数据读取失败,请稍后重试。

View File

@ -4,6 +4,8 @@ namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Modules\EcommerceFinance\Services\JianSunQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\FeatureTestCase;
@ -105,6 +107,68 @@ class SupplierPayablePagesTest extends FeatureTestCase
$this->assertCount(34, $columns[0]);
}
public function testProductPricesPageUsesOperationsReplicaPriceLimitFields(): void
{
$this->grantAdminPermission();
$cache = \Mockery::mock(UpstreamQueryCache::class);
$cache->shouldReceive('remember')
->once()
->withArgs(static function (string $scope, array $parameters): bool {
return $scope === 'supplier-payables.product-prices.list'
&& $parameters['filters']['barcode'] === 'YMJ005203N1176V01'
&& $parameters['filters']['completed_from'] === '2025-12-01'
&& $parameters['filters']['completed_to'] === '2025-12-31';
})
->andReturn(['rows' => [[
'barcode' => 'YMJ005203N1176V01',
'spu' => 'YMJ0052',
'product_name' => '款式二 36/36/176 胡桃木色 BY-008',
'category' => '住宅家具 - 衣帽架',
'production_status' => '销售',
'minimum_price' => 152.8,
'price_limit' => 152.6,
'purchase_price' => 91.11,
'shipping_fee' => 11.62,
'jingxi_limit_price' => 123.0,
'approval_status' => '已完成',
'approval_result' => '已同意',
'workflow_title' => '特殊限价需求申请',
'completed_at' => '2025-12-31 00:00:00',
]]]);
$cache->shouldReceive('responseHeaders')->once()->andReturn([]);
$factory = \Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldReceive('forSource')
->once()
->with('operations_replica')
->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-payables',
'page' => 'product-prices',
'barcode' => 'YMJ005203N1176V01',
'completed_from' => '2025-12-01',
'completed_to' => '2025-12-31',
]));
$response
->assertOk()
->assertSee('商品编码')
->assertSee('最低售价')
->assertSee('计算限价')
->assertSee('采购价')
->assertSee('京喜限价')
->assertSee('YMJ005203N1176V01')
->assertSee('152.80')
->assertSee('已同意')
->assertDontSee('是否采购降本');
preg_match_all('/<th(?:\s|>)/', $response->getContent(), $headers);
$this->assertCount(15, $headers[0]);
}
public function testCompensationPageUsesTheFiveColumnsFromTheReferenceSheet(): void
{
$this->grantAdminPermission();

View File

@ -9,7 +9,6 @@ use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
@ -411,7 +410,6 @@ class JianSunSnapshotRefresherTest extends TestCase
?InventoryRepository $inventory = null,
?AlibabaBillRepository $alibaba = null,
?PinduoduoSpreadBillRepository $pinduoduo = null,
?ProductPriceRepository $productPrices = null,
?RawBoardPayablesRepository $rawBoardPayables = null,
?TmallTaobaoSpreadBillRepository $taobaoAlipay = null,
?SupplierPayableReportRepository $supplierPayableReports = null,
@ -437,7 +435,6 @@ class JianSunSnapshotRefresherTest extends TestCase
$inventory ?? $this->placeholder(InventoryRepository::class),
$alibaba ?? $this->placeholder(AlibabaBillRepository::class),
$pinduoduo ?? $this->placeholder(PinduoduoSpreadBillRepository::class),
$productPrices ?? $this->placeholder(ProductPriceRepository::class),
$rawBoardPayables ?? $this->placeholder(RawBoardPayablesRepository::class),
$taobaoAlipay ?? $this->placeholder(TmallTaobaoSpreadBillRepository::class),
$this->placeholder(SupplierPayableRepository::class),

View File

@ -6,6 +6,7 @@ use App\Business\UpstreamBusinessInformation;
use Closure;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\OrderDataRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceRepository;
use Modules\EcommerceFinance\Services\OperationsReplicaSnapshotRefresher;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
@ -70,7 +71,9 @@ class OperationsReplicaSnapshotRefresherTest extends TestCase
};
$orders = $this->createMock(OrderDataRepository::class);
$refresher = new OperationsReplicaSnapshotRefresher($caches, $information, $orders);
$productPrices = $this->createMock(ProductPriceRepository::class);
$productPrices->expects($this->never())->method('paginate');
$refresher = new OperationsReplicaSnapshotRefresher($caches, $information, $orders, $productPrices);
$this->assertSame('operations_replica', $refresher->sourceKey());
$this->assertSame('fresh', $refresher->refresh(compact('scope', 'parameters')));
@ -119,13 +122,72 @@ class OperationsReplicaSnapshotRefresherTest extends TestCase
->method($method)
->with($parameters['filters'], $parameters['page'], $parameters['limit'])
->willReturn(['rows' => [['id' => 1]]]);
$productPrices = $this->createMock(ProductPriceRepository::class);
$productPrices->expects($this->never())->method('paginate');
$refresher = new OperationsReplicaSnapshotRefresher($caches, $information, $orders);
$refresher = new OperationsReplicaSnapshotRefresher($caches, $information, $orders, $productPrices);
$this->assertSame('fresh', $refresher->refresh(compact('scope', 'parameters')));
$this->assertSame(['rows' => [['id' => 1]]], $cache->value);
}
public function test_product_price_scope_uses_the_operations_replica_repository(): void
{
$cache = new class extends UpstreamQueryCache {
public mixed $value = null;
public function __construct()
{
}
public function refresh(string $scope, array $parameters, Closure $resolver): string
{
$this->value = $resolver();
return 'fresh';
}
};
$caches = new class($cache) extends UpstreamQueryCacheFactory {
public function __construct(private readonly UpstreamQueryCache $cache)
{
}
public function forSource(string $source): UpstreamQueryCache
{
return $this->cache;
}
};
$information = $this->createMock(UpstreamBusinessInformation::class);
$information->expects($this->never())->method('snapshot');
$orders = $this->createMock(OrderDataRepository::class);
$orders->expects($this->never())->method('afterSales');
$orders->expects($this->never())->method('promotionExpenses');
$productPrices = $this->createMock(ProductPriceRepository::class);
$filters = [
'barcode' => 'YMJ005203N1176V01',
'completed_from' => '2025-12-01',
'completed_to' => '2025-12-31',
];
$productPrices->expects($this->once())
->method('paginate')
->with($filters, 2, 50)
->willReturn(['rows' => [['barcode' => 'YMJ005203N1176V01']]]);
$refresher = new OperationsReplicaSnapshotRefresher(
$caches,
$information,
$orders,
$productPrices,
);
$parameters = ['filters' => $filters, 'page' => 2, 'limit' => 50];
$this->assertSame('fresh', $refresher->refresh([
'scope' => 'supplier-payables.product-prices.list',
'parameters' => $parameters,
]));
$this->assertSame(['rows' => [['barcode' => 'YMJ005203N1176V01']]], $cache->value);
}
public static function orderDataScopes(): array
{
$parameters = [

View File

@ -15,43 +15,59 @@ class ProductPriceRepositoryTest extends TestCase
$query->expects($this->once())->method('select')->willReturnCallback(
function (string $sql, array $bindings): array {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('[dbo].[pm_priceAdjustment_log]', $sql);
$this->assertStringContainsString('[skucode] LIKE ?', $sql);
$this->assertStringContainsString("CASE [priceType] WHEN '0' THEN N'定价' WHEN '1' THEN N'变价'", $sql);
$this->assertStringContainsString("WHEN '1' THEN N'清货变价'", $sql);
$this->assertStringContainsString("WHEN '2' THEN N'阶段变价'", $sql);
$this->assertStringContainsString("WHEN '4' THEN N'打折入库'", $sql);
$this->assertStringContainsString("CASE [isCostReduction] WHEN '1' THEN N'是' WHEN '0' THEN N'否'", $sql);
$this->assertStringContainsString('[effectStartTime] < DATEADD(day, 1, CAST(? AS date))', $sql);
$this->assertStringContainsString('[rpt].[Bulk_Price_Limit]', $sql);
$this->assertStringContainsString('[product_code] LIKE ?', $sql);
$this->assertStringContainsString("WHEN 'COMPLETED' THEN N'已完成'", $sql);
$this->assertStringContainsString("WHEN 'agree' THEN N'已同意'", $sql);
$this->assertStringContainsString('[process_end_date] < DATEADD(day, 1, CAST(? AS date))', $sql);
$this->assertStringContainsString('OFFSET 2 ROWS FETCH NEXT 3 ROWS ONLY', $sql);
$this->assertSame(
['%ABC\\%\\_%', '0', '1', '2026-07-01', '2026-07-31'],
['%ABC\\%\\_%', '2026-07-01', '2026-07-31'],
$bindings,
);
return [
['barcode' => 'A', 'price_before' => '10.00', 'price_after' => '11.50', 'price_diff' => '1.50'],
['barcode' => 'B', 'price_before' => '', 'price_after' => null, 'price_diff' => '0.00'],
['barcode' => 'C', 'price_before' => '3.00', 'price_after' => '4.00', 'price_diff' => '1.00'],
[
'barcode' => 'A',
'minimum_price' => '10.00',
'price_limit' => '11.50',
'purchase_price' => '7.25',
'shipping_fee' => '2.00',
'jingxi_limit_price' => '9.50',
],
[
'barcode' => 'B',
'minimum_price' => '',
'price_limit' => null,
'purchase_price' => '0.00',
'shipping_fee' => null,
'jingxi_limit_price' => '',
],
[
'barcode' => 'C',
'minimum_price' => '3.00',
'price_limit' => '4.00',
'purchase_price' => '2.00',
'shipping_fee' => '1.00',
'jingxi_limit_price' => '3.50',
],
];
},
);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query);
$factory->expects($this->once())->method('forSource')->with('operations_replica')->willReturn($query);
$result = (new ProductPriceRepository($factory))->paginate([
'barcode' => 'ABC%_',
'price_type' => 'pricing',
'cost_reduction' => 'yes',
'effective_from' => '2026-07-01',
'effective_to' => '2026-07-31',
'completed_from' => '2026-07-01',
'completed_to' => '2026-07-31',
], 2, 2);
$this->assertCount(3, $result['rows']);
$this->assertSame(10.0, $result['rows'][0]['price_before']);
$this->assertSame(11.5, $result['rows'][0]['price_after']);
$this->assertNull($result['rows'][1]['price_before']);
$this->assertSame(0.0, $result['rows'][1]['price_diff']);
$this->assertSame(10.0, $result['rows'][0]['minimum_price']);
$this->assertSame(11.5, $result['rows'][0]['price_limit']);
$this->assertNull($result['rows'][1]['minimum_price']);
$this->assertSame(0.0, $result['rows'][1]['purchase_price']);
}
}