快递应付:修复三方仓进销存加载超时
This commit is contained in:
parent
26f945ba29
commit
e883d262f0
@ -4248,7 +4248,12 @@ class Pages extends Controller
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '三方仓进销存暂时无法读取,请稍后重试。';
|
||||
$message = strtolower($exception->getMessage());
|
||||
$error = str_contains($message, 'max_statement_time') || str_contains($message, 'query execution was interrupted')
|
||||
? '三方仓进销存查询超时,请稍后重新查询。'
|
||||
: (str_contains($message, 'inventory_report_generation_busy')
|
||||
? '三方仓进销存正在计算,请稍后重新查询。'
|
||||
: '三方仓进销存暂时无法读取,请稍后重试。');
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@ -279,6 +279,18 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
$state = $this->requireReadyState();
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$calculated = $this->calculatedFlowRows($filters, $state);
|
||||
if ($calculated !== null) {
|
||||
$summary = $this->summarizeRows($calculated);
|
||||
|
||||
return [
|
||||
'data' => array_slice($calculated, ($page - 1) * $perPage, $perPage),
|
||||
'total' => $summary['count'],
|
||||
'summary' => $summary,
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
$query = $this->filteredReportQuery($filters, $state);
|
||||
$summary = $this->summarizeQuery($this->filteredReportQuery(
|
||||
$filters,
|
||||
@ -1227,7 +1239,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
}
|
||||
|
||||
/** @param callable():?array $resolver */
|
||||
private function rememberMonthlyInventoryReport(string $key, callable $resolver): ?array
|
||||
private function rememberMonthlyInventoryReport(string $key, callable $resolver, int $waitSeconds = self::REPORT_CACHE_WAIT_SECONDS): ?array
|
||||
{
|
||||
$cached = Cache::get($key);
|
||||
if (is_array($cached)) {
|
||||
@ -1236,7 +1248,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
|
||||
try {
|
||||
return Cache::lock($key . ':lock', self::REPORT_CACHE_LOCK_SECONDS)
|
||||
->block(self::REPORT_CACHE_WAIT_SECONDS, function () use ($key, $resolver): ?array {
|
||||
->block($waitSeconds, function () use ($key, $resolver): ?array {
|
||||
$cached = Cache::get($key);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
@ -1382,6 +1394,10 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
public function summary(array $filters): array
|
||||
{
|
||||
$state = $this->requireReadyState();
|
||||
$calculated = $this->calculatedFlowRows($filters, $state, $this->hasProductFilters($filters));
|
||||
if ($calculated !== null) {
|
||||
return $this->summarizeRows($calculated);
|
||||
}
|
||||
|
||||
return $this->summarizeQuery($this->filteredReportQuery(
|
||||
$filters,
|
||||
@ -1397,6 +1413,17 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
public function filterOptions(array $filters = []): array
|
||||
{
|
||||
$state = $this->requireReadyState();
|
||||
$calculated = $this->calculatedFlowRows($filters, $state);
|
||||
if ($calculated !== null) {
|
||||
$options = [];
|
||||
foreach (['suppliers' => 'supplier', 'categories' => 'category'] as $key => $field) {
|
||||
$options[$key] = collect($calculated)->pluck($field)->filter(
|
||||
static fn (mixed $value): bool => $value !== null && $value !== '',
|
||||
)->unique()->sort()->values()->all();
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
$query = $this->filteredReportQuery($filters, $state);
|
||||
|
||||
$suppliers = (clone $query)->whereNotNull('supplier')->where('supplier', '!=', '')
|
||||
@ -1420,6 +1447,12 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
): Generator
|
||||
{
|
||||
$state = $this->requireReadyState();
|
||||
$calculated = $this->calculatedFlowRows($filters, $state, $includeProductDetails);
|
||||
if ($calculated !== null) {
|
||||
yield from $calculated;
|
||||
|
||||
return;
|
||||
}
|
||||
$batchSize = min(max($batchSize, 1), 5000);
|
||||
$lastDate = null;
|
||||
$lastSku = null;
|
||||
@ -1456,6 +1489,184 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical imports stay on the indexed SQL path. Later periods aggregate
|
||||
* confirmed movements once, then expand only the compact SKU/day balances.
|
||||
* Cache quantities by published generations, independently of page/filters;
|
||||
* product details remain live and are restricted to these report SKUs.
|
||||
*
|
||||
* @return list<array<string,mixed>>|null
|
||||
*/
|
||||
private function calculatedFlowRows(array $filters, array $state, bool $includeProductDetails = true): ?array
|
||||
{
|
||||
$period = $this->period($filters);
|
||||
$authority = $this->authoritativeAnchor();
|
||||
if ($this->authoritativeReportQuery($period, $authority, false) !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = 'third-party-warehouse:daily-quantities:v3:' . hash('sha256', (string) json_encode([
|
||||
'database' => $this->connection->getDatabaseName(),
|
||||
'period' => $period,
|
||||
'authority' => $authority,
|
||||
'state' => array_intersect_key($state, array_flip([
|
||||
'active_header_generation', 'active_item_generation', 'active_inventory_generation', 'last_succeeded_at',
|
||||
])),
|
||||
]));
|
||||
$rows = $this->rememberMonthlyInventoryReport(
|
||||
$key,
|
||||
fn (): array => $this->calculateFlowQuantities($period, $state, $authority),
|
||||
5,
|
||||
) ?? [];
|
||||
$owner = (string) config('upstream_mirrors.third_party_warehouse_storage.owner_name', '常州简尚家居有限公司');
|
||||
foreach ($rows as &$row) {
|
||||
$row['owner'] = $owner;
|
||||
}
|
||||
unset($row);
|
||||
$skuFilter = trim((string) ($filters['sku_code'] ?? ''));
|
||||
if ($skuFilter !== '') {
|
||||
$rows = array_values(array_filter($rows, static fn (array $row): bool =>
|
||||
mb_stripos((string) $row['sku_code'], $skuFilter) !== false));
|
||||
}
|
||||
if ($rows === [] || (! $includeProductDetails && ! $this->hasProductFilters($filters))) {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$skus = array_values(array_unique(array_column($rows, 'sku_code')));
|
||||
$latest = $this->connection->table(self::PRODUCT_TABLE)
|
||||
->where('sku_type', 'normal')->whereIn('sku_id', $skus)
|
||||
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
|
||||
$products = $this->connection->query()->fromSub($latest, 'latest')
|
||||
->join(self::PRODUCT_TABLE . ' as s', function ($join): void {
|
||||
$join->on('s.sku_id', '=', 'latest.sku_id')->on('s.autoid', '=', 'latest.autoid')
|
||||
->where('s.sku_type', '=', 'normal');
|
||||
});
|
||||
foreach (['product_name' => 's.name', 'supplier' => 's.supplier_name', 'category' => 's.category'] as $filter => $column) {
|
||||
$this->containsFilter($products, $column, $filters[$filter] ?? '');
|
||||
}
|
||||
$products->select([
|
||||
's.sku_id', 's.name as product_name', 's.supplier_name as supplier', 's.category',
|
||||
's.vc_name as virtual_category', 's.supplier_sku_id as supplier_style_no', 's.brand', 's.unit',
|
||||
])->selectRaw('COALESCE(s.pic_big, s.pic) AS image_url')
|
||||
->selectRaw('COALESCE(s.short_name, s.name) AS short_name')
|
||||
->selectRaw('COALESCE(s.properties_value, s.color) AS color_spec');
|
||||
$details = $this->boundedGet($products, 10)->keyBy(fn (object $row): string => $this->flowSkuKey((string) $row->sku_id));
|
||||
$requiresProduct = $this->hasProductFilters($filters);
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$product = $details->get($this->flowSkuKey($row['sku_code']));
|
||||
if ($requiresProduct && $product === null) {
|
||||
continue;
|
||||
}
|
||||
$result[] = array_replace($row, array_intersect_key((array) $product, array_flip(self::OUTPUT_COLUMNS)));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function calculateFlowQuantities(array $period, array $state, ?array $authority): array
|
||||
{
|
||||
$anchorAt = $authority['anchor_at'] ?? $this->stateAnchor($state);
|
||||
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
|
||||
$anchors = $authority !== null
|
||||
? $this->connection->table(self::AUTHORITATIVE_ANCHOR_TABLE)
|
||||
->where('generation', $authority['generation'])->select(['sku_code', 'qty', 'anchor_at as snapshot_at'])
|
||||
: $this->connection->table(self::INVENTORY_TABLE)
|
||||
->where('generation', $inventoryGeneration)->where('wms_co_id', self::WAREHOUSE_ID)
|
||||
->select(['sku_code', 'qty', 'captured_at as snapshot_at']);
|
||||
$anchors->whereNotNull('sku_code')->where('sku_code', '!=', '');
|
||||
$snapshots = $this->boundedGet($anchors, 10)->keyBy(fn (object $row): string => $this->flowSkuKey((string) $row->sku_code));
|
||||
$movements = $this->confirmedMovements(
|
||||
$this->generation($state, 'active_header_generation'),
|
||||
$this->generation($state, 'active_item_generation'),
|
||||
)->where('h.occurred_at', '>=', min($period['start'], $anchorAt))
|
||||
->where('h.occurred_at', '<', max($period['end'], $anchorAt));
|
||||
if ($authority !== null) {
|
||||
$movements->leftJoin(self::AUTHORITATIVE_ANCHOR_TABLE . ' as snapshot', function ($join) use ($authority): void {
|
||||
$join->on('snapshot.sku_code', '=', 'i.sku_code')->where('snapshot.generation', $authority['generation']);
|
||||
});
|
||||
$snapshotTime = 'COALESCE(snapshot.anchor_at, ?)';
|
||||
} else {
|
||||
$movements->leftJoin(self::INVENTORY_TABLE . ' as snapshot', function ($join) use ($inventoryGeneration): void {
|
||||
$join->on('snapshot.sku_code', '=', 'i.sku_code')->where('snapshot.generation', $inventoryGeneration)
|
||||
->where('snapshot.wms_co_id', self::WAREHOUSE_ID);
|
||||
});
|
||||
$snapshotTime = 'COALESCE(snapshot.captured_at, ?)';
|
||||
}
|
||||
$signed = '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0))';
|
||||
$movements->groupBy('i.sku_code')->groupByRaw('DATE(h.occurred_at)')->select('i.sku_code')
|
||||
->selectRaw('DATE(h.occurred_at) AS report_date')
|
||||
->selectRaw('SUM(CASE WHEN h.occurred_at ' . ($authority === null ? '>' : '>=') . ' '
|
||||
. $snapshotTime . ' THEN ' . $signed . ' ELSE 0 END) AS forward_qty', [$anchorAt])
|
||||
->selectRaw('SUM(CASE WHEN h.occurred_at < ' . $snapshotTime . ' THEN '
|
||||
. $signed . ' ELSE 0 END) AS backward_qty', [$anchorAt]);
|
||||
foreach (self::MOVEMENT_COLUMNS as $field => $type) {
|
||||
$movements->selectRaw('SUM(CASE WHEN h.io_type = ? THEN ' . $signed . ' ELSE 0 END) AS ' . $field, [$type]);
|
||||
}
|
||||
$daily = $this->boundedGet($movements, 15)->groupBy(fn (object $row): string => $this->flowSkuKey((string) $row->sku_code));
|
||||
$skus = array_unique([...$snapshots->keys()->all(), ...$daily->keys()->all()]);
|
||||
sort($skus, SORT_STRING);
|
||||
$byDate = [];
|
||||
foreach ($skus as $sku) {
|
||||
$snapshot = $snapshots->get($sku);
|
||||
$displaySku = (string) ($snapshot->sku_code ?? $daily->get($sku)?->first()?->sku_code ?? $sku);
|
||||
$snapshotAt = (string) ($snapshot->snapshot_at ?? $anchorAt);
|
||||
$flows = $daily->get($sku, collect())->sortBy('report_date')->keyBy('report_date');
|
||||
$forward = 0.0;
|
||||
$backward = (float) $flows->sum('backward_qty');
|
||||
foreach ($flows as $date => $flow) {
|
||||
if ($date < substr($period['start'], 0, 10)) {
|
||||
$forward += (float) $flow->forward_qty;
|
||||
$backward -= (float) $flow->backward_qty;
|
||||
}
|
||||
}
|
||||
$cursor = Carbon::parse($period['start']);
|
||||
$end = Carbon::parse($period['end']);
|
||||
while ($cursor < $end) {
|
||||
$date = $cursor->toDateString();
|
||||
$flow = $flows->get($date);
|
||||
$forward += (float) ($flow->forward_qty ?? 0);
|
||||
$backward -= (float) ($flow->backward_qty ?? 0);
|
||||
$dayEnd = $cursor->copy()->addDay()->toDateTimeString();
|
||||
$closing = round((float) ($snapshot->qty ?? 0) + ($snapshotAt < $dayEnd ? $forward : -$backward), 4);
|
||||
$row = ['report_date' => $date, 'sku_code' => $displaySku, 'closing_qty' => $closing, 'opening_aux' => 0];
|
||||
$net = 0.0;
|
||||
$nonZero = false;
|
||||
foreach (self::MOVEMENT_COLUMNS as $field => $type) {
|
||||
$row[$field] = round((float) ($flow->{$field} ?? 0), 4);
|
||||
$net += $row[$field];
|
||||
$nonZero = $nonZero || $row[$field] != 0;
|
||||
}
|
||||
$row['opening_qty'] = round($closing - $net, 4);
|
||||
if ($nonZero || $closing != 0 || $row['opening_qty'] != 0) {
|
||||
$byDate[$date][] = $this->mapRow((object) $row);
|
||||
}
|
||||
$cursor->addDay();
|
||||
}
|
||||
}
|
||||
ksort($byDate);
|
||||
|
||||
return array_merge([], ...array_values($byDate));
|
||||
}
|
||||
|
||||
private function flowSkuKey(string $sku): string
|
||||
{
|
||||
// MariaDB's shared mirror uses case-insensitive SKU joins. PHP keys
|
||||
// must use the same identity or mixed-case movements duplicate SKUs.
|
||||
return $this->connection->getDriverName() === 'mysql' ? mb_strtolower($sku) : $sku;
|
||||
}
|
||||
|
||||
/** @return array{count:int,opening_qty:float,closing_qty:float} */
|
||||
private function summarizeRows(array $rows): array
|
||||
{
|
||||
return [
|
||||
'count' => count($rows),
|
||||
'opening_qty' => round(array_sum(array_column($rows, 'opening_qty')), 4),
|
||||
'closing_qty' => round(array_sum(array_column($rows, 'closing_qty')), 4),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function filteredReportQuery(
|
||||
array $filters,
|
||||
|
||||
@ -57,34 +57,44 @@
|
||||
frame.src = link.href;
|
||||
frame.style.visibility = 'hidden';
|
||||
status.textContent = '正在加载…';
|
||||
const fail = () => {
|
||||
const fail = (message = '页面暂时无法加载,请重试。') => {
|
||||
frame.dataset.failed = '1';
|
||||
frame.dataset.failureMessage = message;
|
||||
if (selected === link) {
|
||||
status.textContent = '页面加载失败或登录已过期,请重试。';
|
||||
status.textContent = message;
|
||||
retry.hidden = false;
|
||||
}
|
||||
};
|
||||
const timeout = setTimeout(fail, 45000);
|
||||
frame.addEventListener('error', fail);
|
||||
const timeout = setTimeout(() => fail('页面响应超时,请稍后重试。'), 45000);
|
||||
frame.addEventListener('error', () => fail());
|
||||
frame.addEventListener('load', () => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const doc = frame.contentDocument;
|
||||
const content = doc.querySelector('#app');
|
||||
if (!content) { fail(); return; }
|
||||
const content = doc?.querySelector('#app');
|
||||
if (!content) {
|
||||
const path = frame.contentWindow.location.pathname;
|
||||
const text = doc?.body?.textContent || '';
|
||||
if (/(?:^|\/)login(?:\/|$)/.test(path)) fail('登录已过期,请重新登录。');
|
||||
else if (/504|Gateway Timeout/i.test((doc?.title || '') + text)) fail('页面响应超时,请稍后重试。');
|
||||
else if (/403|Forbidden/i.test((doc?.title || '') + text)) fail('当前账号没有该页面权限。');
|
||||
else fail();
|
||||
return;
|
||||
}
|
||||
// Preserve each page's own scripts, forms and routes in its document.
|
||||
const style = doc.createElement('style');
|
||||
style.textContent = '.js-menu,body>.container,#header [data-page-title-first]{display:none!important} #panel{margin:0!important;padding:0!important} #main-body>.container{margin:0!important;padding:0!important;width:100%!important} #header{padding:0!important;margin:0!important}';
|
||||
doc.head.appendChild(style);
|
||||
frame.style.visibility = 'visible';
|
||||
delete frame.dataset.failed;
|
||||
delete frame.dataset.failureMessage;
|
||||
if (selected === link) { status.textContent = ''; retry.hidden = true; }
|
||||
} catch (_) { fail(); }
|
||||
});
|
||||
root.querySelector('[data-courier-frames]').appendChild(frame);
|
||||
frames.set(link.dataset.courierTab, frame);
|
||||
} else {
|
||||
status.textContent = frame.dataset.failed ? '页面加载失败,请重试。' : '';
|
||||
status.textContent = frame.dataset.failed ? frame.dataset.failureMessage : '';
|
||||
retry.hidden = !frame.dataset.failed;
|
||||
}
|
||||
frame.hidden = false;
|
||||
|
||||
@ -201,6 +201,23 @@ class ThirdPartyWarehouseStoragePageTest extends FeatureTestCase
|
||||
->assertSee('不会显示空白数据冒充正式结果');
|
||||
}
|
||||
|
||||
public function test_query_timeout_preserves_filters_and_explains_the_actual_failure(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->andThrow(new \RuntimeException('max_statement_time exceeded'));
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables', 'page' => 'third-party-warehouse-storage',
|
||||
'period' => '2026-09', 'sku_code' => 'SKU-TIMEOUT',
|
||||
]))->assertOk()->assertSee('三方仓进销存查询超时')
|
||||
->assertSee('value="2026-09"', false)->assertSee('value="SKU-TIMEOUT"', false)
|
||||
->assertDontSee('登录已过期');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function row(): array
|
||||
{
|
||||
|
||||
@ -882,6 +882,76 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame('SKU-B', $export->current()['sku_code']);
|
||||
}
|
||||
|
||||
public function test_daily_aggregation_matches_the_original_balance_query_across_snapshot_boundaries(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-FRACTION');
|
||||
$this->insertInventory('SKU-FRACTION', 1.2345, '2026-09-03 12:00:00');
|
||||
foreach ([
|
||||
['采购进仓', 0.1234, 1, '2026-09-01 00:00:00'],
|
||||
['销售出仓', 0.2345, -1, '2026-09-02 23:59:59'],
|
||||
['采购进仓', 0.3456, 1, '2026-09-03 11:59:59'],
|
||||
['采购进仓', 0.4567, 1, '2026-09-03 12:00:00'],
|
||||
['销售出仓', 0.5678, -1, '2026-09-03 12:00:01'],
|
||||
['采购进仓', 0.6789, 1, '2026-09-04 00:00:00'],
|
||||
] as [$type, $qty, $direction, $time]) {
|
||||
$this->insertMovement('SKU-FRACTION', $type, $qty, $direction, $time);
|
||||
}
|
||||
$filters = $this->filters(['period' => '2026-09']);
|
||||
$queryMethod = new \ReflectionMethod($this->repository, 'filteredReportQuery');
|
||||
$mapMethod = new \ReflectionMethod($this->repository, 'mapRow');
|
||||
$expected = $queryMethod->invoke($this->repository, $filters, $this->repository->state())
|
||||
->orderBy('report_date')->orderBy('sku_code')->get()
|
||||
->map(fn ($row) => $mapMethod->invoke($this->repository, $row))->all();
|
||||
|
||||
$actual = $this->repository->paginate($filters, 1, 100);
|
||||
$this->assertSame($expected, $actual['data']);
|
||||
$this->assertSame(count($expected), $actual['total']);
|
||||
$this->assertSame($expected, iterator_to_array($this->repository->exportRows($filters, 1), false));
|
||||
}
|
||||
|
||||
public function test_paging_reuses_quantities_and_refreshes_after_a_new_mirror_publication(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-CACHE');
|
||||
$this->insertInventory('SKU-CACHE', 5, '2026-09-01 00:00:00');
|
||||
$filters = $this->filters(['period' => '2026-09']);
|
||||
$first = $this->repository->paginate($filters, 1, 1);
|
||||
DB::connection('business_mirror')->enableQueryLog();
|
||||
$second = $this->repository->paginate($filters, 2, 1);
|
||||
$queries = DB::connection('business_mirror')->getQueryLog();
|
||||
DB::connection('business_mirror')->disableQueryLog();
|
||||
|
||||
$this->assertSame('2026-09-02', $second['data'][0]['date']);
|
||||
$this->assertSame($first['summary'], $second['summary']);
|
||||
$this->assertFalse(collect($queries)->contains(fn ($q) => str_contains($q['query'], 'from "third_party_warehouse_inout_items"')
|
||||
|| str_contains($q['query'], 'join "third_party_warehouse_inout_items"')));
|
||||
|
||||
$this->insertMovement('SKU-CACHE', '采购进仓', 2, 1, '2026-09-01 12:00:00');
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
|
||||
->update(['last_succeeded_at' => '2026-09-12 01:00:00']);
|
||||
$refreshed = $this->repository->paginate($filters, 1, 1);
|
||||
$this->assertSame(7.0, $refreshed['data'][0]['closing_qty']);
|
||||
|
||||
DB::connection('business_mirror')->table('ops_jst_sku')->update(['supplier_name' => '新供应商']);
|
||||
$filtered = $this->repository->paginate($filters + ['unused' => 'ignored'], 1, 1);
|
||||
$this->assertSame('新供应商', $filtered['data'][0]['supplier']);
|
||||
}
|
||||
|
||||
public function test_cancelled_fractional_movements_do_not_leave_phantom_zero_inventory_rows(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-ZERO');
|
||||
$this->insertInventory('SKU-ZERO', 0.3, '2026-09-01 00:00:00');
|
||||
$this->insertMovement('SKU-ZERO', '销售出仓', 0.1, -1, '2026-09-01 08:00:00');
|
||||
$this->insertMovement('SKU-ZERO', '销售出仓', 0.2, -1, '2026-09-01 09:00:00');
|
||||
$result = $this->repository->paginate($this->filters(['period' => '2026-09']), 1, 100);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame(0.0, $result['data'][0]['closing_qty']);
|
||||
$this->assertSame(0.3, $result['data'][0]['opening_qty']);
|
||||
}
|
||||
|
||||
private function createMirrorTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user