接入并校准三方仓进销存 #176
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageAuthoritativeAnchorImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportThirdPartyWarehouseStorageAuthoritativeAnchor extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:import-third-party-warehouse-anchor
|
||||
{path : 聚水潭三方仓按商品加按天多维分析完整 XLSX 导出文件}';
|
||||
|
||||
protected $description = '导入三方仓权威日进销存明细及月末库存锚点,用于稳定历史余额';
|
||||
|
||||
public function handle(ThirdPartyWarehouseStorageAuthoritativeAnchorImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('三方仓权威进销存导入失败,已保留上一版有效数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'三方仓权威日明细导入完成:校验 %s 条明细,生成 %s 个商品锚点,锚点时刻 %s。',
|
||||
number_format($result['source_rows']),
|
||||
number_format($result['anchor_skus']),
|
||||
$result['anchor_at'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
63
app/Console/Commands/SyncThirdPartyWarehouseStorage.php
Normal file
63
app/Console/Commands/SyncThirdPartyWarehouseStorage.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncThirdPartyWarehouseStorage extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-third-party-warehouse-storage
|
||||
{--full : 启动或继续三方仓流水与库存的全量校准}
|
||||
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
|
||||
|
||||
protected $description = '同步南昌樵舍三方仓按商品进销存数据到共享业务镜像';
|
||||
|
||||
public function handle(ThirdPartyWarehouseStorageSynchronizer $synchronizer): int
|
||||
{
|
||||
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
(bool) $this->option('full'),
|
||||
$this->option('max-batches') === null ? null : (int) $this->option('max-batches'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('三方仓仓储数据共享镜像同步失败。', [
|
||||
'dataset' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
$this->error('三方仓仓储数据同步失败,页面继续读取上一版完整数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'requires_full_sync') {
|
||||
$this->warn('三方仓仓储数据尚未完成首次全量同步,请显式使用 --full。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'already_running') {
|
||||
$this->line('三方仓仓储数据已有同步任务运行,本次不重复启动。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'三方仓仓储数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。',
|
||||
$result['mode'] === 'full' ? '全量校准' : '增量同步',
|
||||
$result['status'] === 'succeeded' ? '完成' : '进行中',
|
||||
$result['batches'],
|
||||
$result['headers'],
|
||||
$result['items'],
|
||||
$result['inventory'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,11 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
|
||||
->dailyAt('00:00')
|
||||
->withoutOverlapping(180)
|
||||
->runInBackground();
|
||||
|
||||
if ((bool) config('services.esign.polling_enabled', false)) {
|
||||
$schedule->command('esign:poll-signatures --quiet')
|
||||
->cron('*/2 * * * *')
|
||||
|
||||
@ -30,6 +30,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
|
||||
use Modules\EcommerceFinance\View\BillOffsetPageData;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
|
||||
use Modules\EcommerceFinance\Repositories\InventoryRepository;
|
||||
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
|
||||
@ -80,6 +81,7 @@ class Pages extends Controller
|
||||
private readonly ProductPriceMirrorRepository $productPrices,
|
||||
private readonly CourierInformationMirrorRepository $courierInformation,
|
||||
private readonly CourierFreightReconciliationMirrorRepository $courierFreightReconciliations,
|
||||
private readonly ThirdPartyWarehouseStorageMirrorRepository $thirdPartyWarehouseStorage,
|
||||
private readonly RawBoardPayablesRepository $rawBoardPayables,
|
||||
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
|
||||
private readonly DingOnlineDaifaPriceMirrorRepository $onlineDaifaPrices,
|
||||
@ -619,6 +621,7 @@ class Pages extends Controller
|
||||
'product-prices' => $this->contentForProductPrices(),
|
||||
'courier-information' => $this->contentForCourierInformation(),
|
||||
'freight-reconciliation' => $this->contentForFreightReconciliation(),
|
||||
'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(),
|
||||
'board-deductions' => $this->contentForBoardDeductions(),
|
||||
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
|
||||
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
|
||||
@ -732,6 +735,182 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
protected function contentForThirdPartyWarehouseStorage(): array
|
||||
{
|
||||
$request = request();
|
||||
$filters = $this->thirdPartyWarehouseStorageFilters($request);
|
||||
$page = max((int) $request->query('page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->except('page'),
|
||||
]);
|
||||
$state = null;
|
||||
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
$summary = [];
|
||||
$error = null;
|
||||
$pending = false;
|
||||
|
||||
try {
|
||||
if (! $this->thirdPartyWarehouseStorage->available()) {
|
||||
$error = '共享业务镜像尚未建立,三方仓进销存暂时无法读取。';
|
||||
} elseif (! $this->thirdPartyWarehouseStorage->ready()) {
|
||||
$state = $this->thirdPartyWarehouseStorage->state();
|
||||
$sync = $this->thirdPartyWarehouseStorage->syncStatus($state);
|
||||
$pending = true;
|
||||
$error = ($sync['code'] ?? null) === 'dependency_pending'
|
||||
? '普通商品资料镜像正在执行首次全量同步,完成前不会展示口径不完整的商品属性。'
|
||||
: '三方仓进销存正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
|
||||
} else {
|
||||
$result = $this->thirdPartyWarehouseStorage->paginate($filters, $page, $limit);
|
||||
$rows = new LengthAwarePaginator(
|
||||
Collection::make($result['data'] ?? []),
|
||||
(int) ($result['total'] ?? 0),
|
||||
$limit,
|
||||
$page,
|
||||
[
|
||||
'path' => $request->url(),
|
||||
'query' => $request->except('page'),
|
||||
],
|
||||
);
|
||||
$state = $result['state'] ?? null;
|
||||
$sync = $result['sync'] ?? $sync;
|
||||
$summary = $result['summary'] ?? [];
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '三方仓进销存暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.courier-payables.third-party-warehouse-storage',
|
||||
'thirdPartyWarehouseRows' => $rows,
|
||||
'thirdPartyWarehouseFilters' => $filters,
|
||||
'thirdPartyWarehouseSummary' => $summary,
|
||||
'thirdPartyWarehouseState' => $state,
|
||||
'thirdPartyWarehouseSync' => $sync,
|
||||
'thirdPartyWarehouseError' => $error,
|
||||
'thirdPartyWarehousePending' => $pending,
|
||||
'thirdPartyWarehouseExportUrl' => route('business.courier-payables.third-party-warehouse-storage.export'),
|
||||
];
|
||||
}
|
||||
|
||||
public function exportThirdPartyWarehouseStorage(Request $request): StreamedResponse
|
||||
{
|
||||
abort_unless(
|
||||
$this->thirdPartyWarehouseStorage->available() && $this->thirdPartyWarehouseStorage->ready(),
|
||||
503,
|
||||
);
|
||||
|
||||
$filters = $this->thirdPartyWarehouseStorageFilters($request);
|
||||
$columns = $this->filterExportColumns(
|
||||
$this->thirdPartyWarehouseStorageExportColumns(),
|
||||
$request->query('fields'),
|
||||
);
|
||||
$period = str_replace('-', '', $filters['period']);
|
||||
|
||||
return response()->streamDownload(function () use ($filters, $columns): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new \RuntimeException('无法创建三方仓进销存导出流。');
|
||||
}
|
||||
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_column($columns, 'label'));
|
||||
|
||||
foreach ($this->thirdPartyWarehouseStorage->exportRows($filters) as $row) {
|
||||
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
|
||||
$value = data_get($row, $column['field']);
|
||||
|
||||
return match ($column['type'] ?? 'text') {
|
||||
'number', 'money' => $value === null || $value === '' ? '' : $value,
|
||||
'code' => $this->csvIdentifier($value),
|
||||
default => $this->csvText($value),
|
||||
};
|
||||
}, $columns));
|
||||
}
|
||||
|
||||
fclose($csv);
|
||||
}, '三方仓进销存_' . $period . '_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function thirdPartyWarehouseStorageFilters(Request $request): array
|
||||
{
|
||||
$dimension = in_array((string) $request->query('dimension'), ['month', 'day'], true)
|
||||
? (string) $request->query('dimension')
|
||||
: 'month';
|
||||
$period = trim((string) $request->query(
|
||||
'period',
|
||||
$dimension === 'day' ? $request->query('date', '') : $request->query('month', ''),
|
||||
));
|
||||
|
||||
if ($dimension === 'day') {
|
||||
if ($this->dateFilter($period) === '') {
|
||||
$period = now()->subDay()->toDateString();
|
||||
}
|
||||
if ($period < ThirdPartyWarehouseStorageMirrorRepository::START_DATE) {
|
||||
$period = ThirdPartyWarehouseStorageMirrorRepository::START_DATE;
|
||||
}
|
||||
} elseif (preg_match('/^(\d{4})-(\d{2})$/', $period, $matches) !== 1
|
||||
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
|
||||
$period = now()->startOfMonth()->subMonth()->format('Y-m');
|
||||
}
|
||||
if ($dimension === 'month'
|
||||
&& $period < substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7)) {
|
||||
$period = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7);
|
||||
}
|
||||
|
||||
$clean = static fn (mixed $value, int $length): string => mb_substr(trim((string) $value), 0, $length);
|
||||
|
||||
return [
|
||||
'dimension' => $dimension,
|
||||
'period' => $period,
|
||||
'sku_code' => $clean($request->query('sku_code', ''), 80),
|
||||
'product_name' => $clean($request->query('product_name', ''), 120),
|
||||
'supplier' => $clean($request->query('supplier', ''), 120),
|
||||
'category' => $clean($request->query('category', ''), 120),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,array{label:string,field:string,type?:string}> */
|
||||
private function thirdPartyWarehouseStorageExportColumns(): array
|
||||
{
|
||||
return [
|
||||
['field' => 'owner', 'label' => '货主'],
|
||||
['field' => 'date', 'label' => '日期'],
|
||||
['field' => 'sku_code', 'label' => '商品编码', 'type' => 'code'],
|
||||
['field' => 'image_url', 'label' => '图片'],
|
||||
['field' => 'product_name', 'label' => '商品名称'],
|
||||
['field' => 'short_name', 'label' => '商品简称'],
|
||||
['field' => 'supplier', 'label' => '供应商'],
|
||||
['field' => 'category', 'label' => '分类'],
|
||||
['field' => 'virtual_category', 'label' => '虚拟分类'],
|
||||
['field' => 'supplier_style_no', 'label' => '供应商款号', 'type' => 'code'],
|
||||
['field' => 'color_spec', 'label' => '颜色规格'],
|
||||
['field' => 'brand', 'label' => '品牌'],
|
||||
['field' => 'unit', 'label' => '单位'],
|
||||
['field' => 'opening_qty', 'label' => '期初数量', 'type' => 'number'],
|
||||
['field' => 'closing_qty', 'label' => '期末数量', 'type' => 'number'],
|
||||
['field' => 'processing_in', 'label' => '加工进仓', 'type' => 'number'],
|
||||
['field' => 'processing_out', 'label' => '加工出仓', 'type' => 'number'],
|
||||
['field' => 'purchase_in', 'label' => '采购进仓', 'type' => 'number'],
|
||||
['field' => 'purchase_return', 'label' => '采购退货', 'type' => 'number'],
|
||||
['field' => 'other_in', 'label' => '其它进仓', 'type' => 'number'],
|
||||
['field' => 'other_return', 'label' => '其它退货', 'type' => 'number'],
|
||||
['field' => 'sales_out', 'label' => '销售出仓', 'type' => 'number'],
|
||||
['field' => 'sales_return', 'label' => '销售退货', 'type' => 'number'],
|
||||
['field' => 'stocktake', 'label' => '盘点', 'type' => 'number'],
|
||||
['field' => 'transfer_out', 'label' => '调拨出', 'type' => 'number'],
|
||||
['field' => 'transfer_in', 'label' => '调拨入', 'type' => 'number'],
|
||||
['field' => 'opening_aux', 'label' => '期初', 'type' => 'number'],
|
||||
['field' => 'other_out', 'label' => '其它出库', 'type' => 'number'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function contentForDingPayableWorkflows(): array
|
||||
{
|
||||
$normalizeDate = static function (mixed $value): string {
|
||||
|
||||
@ -115,6 +115,9 @@ return [
|
||||
'jiansun.dbo.jst_order_express_details' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账物流明细'],
|
||||
'jiansun.dbo.Jst_SendData_View_extension' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账商品与收货区域'],
|
||||
'jiansun.dbo.jst_salesOutboundInfo' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账出库运费'],
|
||||
'jiansun.dbo.jst_inoutWater' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '三方仓进销存'],
|
||||
'jiansun.dbo.jst_inoutWaterItem' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '三方仓进销存'],
|
||||
'jiansun.dbo.jst_inventoryInfo' => ['role' => 'operational', 'target_store' => 'mariadb', 'consumer' => '三方仓进销存'],
|
||||
'jiansun.dbo.finance_boardDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '板材扣款'],
|
||||
'jiansun.dbo.pm_plainPlateData' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '素板资料'],
|
||||
'jiansun.dbo.pm_boardPurchasePrice' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '板材采购价'],
|
||||
|
||||
@ -497,6 +497,7 @@ $navigation = [
|
||||
['slug' => 'courier-information', 'title' => '快递资料'],
|
||||
['slug' => 'agreement-signing', 'title' => '协议签署'],
|
||||
['slug' => 'freight-reconciliation', 'title' => '运费对账'],
|
||||
['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'],
|
||||
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
|
||||
['slug' => 'compensation-details', 'title' => '赔偿明细'],
|
||||
['slug' => 'advance-details', 'title' => '借支明细'],
|
||||
|
||||
@ -87,6 +87,27 @@ return [
|
||||
'overlap_rows' => (int) env('STORE_REPORT_MIRROR_OVERLAP_ROWS', 50000),
|
||||
],
|
||||
|
||||
// 聚水潭进销存报表:固定只读“三方-南昌樵舍仓”,页面只读共享镜像。
|
||||
// 头表按 Id 区间小批读取并固定仓库,避免对上游流水表做无界扫描。
|
||||
'third_party_warehouse_storage' => [
|
||||
'dataset_key' => 'jiansun.third-party-warehouse-storage',
|
||||
'source_key' => 'jiansun',
|
||||
'warehouse_id' => 15045364,
|
||||
'warehouse_name' => '三方-南昌樵舍仓',
|
||||
'owner_name' => '常州简尚家居有限公司',
|
||||
'header_source_object' => 'dbo.jst_inoutWater',
|
||||
'item_source_object' => 'dbo.jst_inoutWaterItem',
|
||||
'inventory_source_object' => 'dbo.jst_inventoryInfo',
|
||||
'header_scan_size' => (int) env('THIRD_PARTY_WAREHOUSE_HEADER_SCAN_SIZE', 10000),
|
||||
'max_header_batches_per_run' => (int) env('THIRD_PARTY_WAREHOUSE_MAX_HEADER_BATCHES', 10),
|
||||
'incremental_batch_size' => (int) env('THIRD_PARTY_WAREHOUSE_INCREMENTAL_BATCH_SIZE', 5000),
|
||||
'incremental_overlap_hours' => (int) env('THIRD_PARTY_WAREHOUSE_INCREMENTAL_OVERLAP_HOURS', 48),
|
||||
'inventory_batch_size' => (int) env('THIRD_PARTY_WAREHOUSE_INVENTORY_BATCH_SIZE', 2000),
|
||||
'sync_lock_seconds' => (int) env('THIRD_PARTY_WAREHOUSE_SYNC_LOCK_SECONDS', 21600),
|
||||
'generation_retention_hours' => (int) env('THIRD_PARTY_WAREHOUSE_GENERATION_RETENTION_HOURS', 48),
|
||||
'stale_after_minutes' => (int) env('THIRD_PARTY_WAREHOUSE_STALE_MINUTES', 1560),
|
||||
],
|
||||
|
||||
'product_prices' => [
|
||||
'dataset_key' => 'supplier-payables.product-prices',
|
||||
'source_key' => 'operations_replica',
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if (! $schema->hasTable('third_party_warehouse_sync_states')) {
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key', 100)->primary();
|
||||
$table->string('status', 32)->default('pending');
|
||||
$table->string('run_mode', 24)->nullable();
|
||||
$table->string('sync_phase', 24)->nullable();
|
||||
$table->uuid('run_token')->nullable();
|
||||
$table->uuid('active_header_generation')->nullable();
|
||||
$table->uuid('active_item_generation')->nullable();
|
||||
$table->uuid('active_inventory_generation')->nullable();
|
||||
$table->uuid('pending_header_generation')->nullable();
|
||||
$table->uuid('pending_item_generation')->nullable();
|
||||
$table->uuid('pending_inventory_generation')->nullable();
|
||||
$table->unsignedBigInteger('header_cursor')->default(0);
|
||||
$table->unsignedBigInteger('header_high_watermark')->nullable();
|
||||
$table->dateTime('header_window_started_at', 3)->nullable();
|
||||
$table->dateTime('header_window_ends_at', 3)->nullable();
|
||||
$table->dateTime('header_modified_cursor_at', 3)->nullable();
|
||||
$table->unsignedBigInteger('header_modified_cursor_id')->default(0);
|
||||
$table->unsignedBigInteger('inventory_cursor')->default(0);
|
||||
$table->unsignedBigInteger('inventory_high_watermark')->nullable();
|
||||
$table->unsignedBigInteger('header_row_count')->default(0);
|
||||
$table->unsignedBigInteger('item_row_count')->default(0);
|
||||
$table->unsignedBigInteger('inventory_row_count')->default(0);
|
||||
$table->dateTime('initial_completed_at', 6)->nullable();
|
||||
$table->dateTime('last_attempted_at', 6)->nullable();
|
||||
$table->dateTime('last_succeeded_at', 6)->nullable();
|
||||
$table->dateTime('last_full_succeeded_at', 6)->nullable();
|
||||
$table->string('last_error_code', 191)->nullable();
|
||||
$table->timestamps(6);
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('third_party_warehouse_generation_releases')) {
|
||||
$schema->create('third_party_warehouse_generation_releases', function (Blueprint $table): void {
|
||||
$table->uuid('release_id')->primary();
|
||||
$table->string('dataset_key', 100);
|
||||
$table->uuid('header_generation');
|
||||
$table->uuid('item_generation');
|
||||
$table->uuid('inventory_generation');
|
||||
$table->string('status', 16);
|
||||
$table->dateTime('published_at', 6);
|
||||
$table->dateTime('retired_at', 6)->nullable();
|
||||
$table->timestamps(6);
|
||||
$table->unique(
|
||||
['dataset_key', 'header_generation', 'item_generation', 'inventory_generation'],
|
||||
'tpw_generation_release_unique',
|
||||
);
|
||||
$table->index(
|
||||
['dataset_key', 'status', 'retired_at'],
|
||||
'tpw_generation_release_status_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('third_party_warehouse_inout_headers')) {
|
||||
$schema->create('third_party_warehouse_inout_headers', function (Blueprint $table): void {
|
||||
$table->bigIncrements('_mirror_id');
|
||||
$table->uuid('generation');
|
||||
$table->string('source_key', 64)->default('jiansun');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->string('status', 32)->nullable();
|
||||
$table->string('io_type', 64)->nullable();
|
||||
$table->dateTime('occurred_at', 3)->nullable();
|
||||
$table->dateTime('source_modified_at', 3)->nullable();
|
||||
$table->string('row_hash', 64);
|
||||
$table->dateTime('synced_at', 6);
|
||||
$table->unique(['generation', 'id'], 'tpw_headers_generation_id_unique');
|
||||
$table->index(['generation', 'io_id'], 'tpw_headers_generation_io_index');
|
||||
$table->index(
|
||||
['generation', 'status', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_report_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('third_party_warehouse_inout_items')) {
|
||||
$schema->create('third_party_warehouse_inout_items', function (Blueprint $table): void {
|
||||
$table->bigIncrements('_mirror_id');
|
||||
$table->uuid('generation');
|
||||
$table->string('source_key', 64)->default('jiansun');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->string('sku_code', 191);
|
||||
$table->decimal('qty', 20, 4)->default(0);
|
||||
$table->smallInteger('qty_type')->default(1);
|
||||
$table->dateTime('source_modified_at', 3)->nullable();
|
||||
$table->string('row_hash', 64);
|
||||
$table->dateTime('synced_at', 6);
|
||||
$table->unique(['generation', 'id'], 'tpw_items_generation_id_unique');
|
||||
$table->index(['generation', 'io_id', 'id'], 'tpw_items_generation_io_index');
|
||||
$table->index(['generation', 'sku_code', 'io_id'], 'tpw_items_generation_sku_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('third_party_warehouse_inventory_snapshots')) {
|
||||
$schema->create('third_party_warehouse_inventory_snapshots', function (Blueprint $table): void {
|
||||
$table->bigIncrements('_mirror_id');
|
||||
$table->uuid('generation');
|
||||
$table->string('source_key', 64)->default('jiansun');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->string('sku_code', 191);
|
||||
$table->decimal('qty', 20, 4)->default(0);
|
||||
$table->dateTime('source_modified_at', 3)->nullable();
|
||||
$table->dateTime('captured_at', 6);
|
||||
$table->string('row_hash', 64);
|
||||
$table->dateTime('synced_at', 6);
|
||||
$table->unique(['generation', 'id'], 'tpw_inventory_generation_id_unique');
|
||||
$table->unique(
|
||||
['generation', 'wms_co_id', 'sku_code'],
|
||||
'tpw_inventory_generation_warehouse_sku_unique',
|
||||
);
|
||||
$table->index(['generation', 'sku_code', 'captured_at'], 'tpw_inventory_report_index');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
$schema->dropIfExists('third_party_warehouse_inventory_snapshots');
|
||||
$schema->dropIfExists('third_party_warehouse_inout_items');
|
||||
$schema->dropIfExists('third_party_warehouse_inout_headers');
|
||||
$schema->dropIfExists('third_party_warehouse_generation_releases');
|
||||
$schema->dropIfExists('third_party_warehouse_sync_states');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if ($schema->hasTable('third_party_warehouse_sync_states')
|
||||
&& ! $schema->hasColumn('third_party_warehouse_sync_states', 'run_token')) {
|
||||
$schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->uuid('run_token')->nullable()->after('sync_phase');
|
||||
});
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'third_party_warehouse_inout_headers',
|
||||
'third_party_warehouse_inout_items',
|
||||
'third_party_warehouse_inventory_snapshots',
|
||||
] as $tableName) {
|
||||
if ($schema->hasTable($tableName) && ! $schema->hasColumn($tableName, 'source_key')) {
|
||||
$schema->table($tableName, function (Blueprint $table): void {
|
||||
$table->string('source_key', 64)->default('jiansun')->after('generation');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// These required columns may have been created by the base migration.
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if (! $schema->hasTable('third_party_warehouse_authoritative_anchor_states')) {
|
||||
$schema->create('third_party_warehouse_authoritative_anchor_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key', 120)->primary();
|
||||
$table->string('status', 24)->default('pending');
|
||||
$table->uuid('active_generation')->nullable();
|
||||
$table->uuid('pending_generation')->nullable();
|
||||
$table->dateTime('anchor_at')->nullable();
|
||||
$table->string('source_filename', 255)->nullable();
|
||||
$table->char('source_sha256', 64)->nullable();
|
||||
$table->date('source_period_start')->nullable();
|
||||
$table->date('source_period_end')->nullable();
|
||||
$table->unsignedBigInteger('source_row_count')->default(0);
|
||||
$table->unsignedBigInteger('anchor_sku_count')->default(0);
|
||||
$table->dateTime('last_attempted_at')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('last_error_code', 120)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('third_party_warehouse_authoritative_anchors')) {
|
||||
$schema->create('third_party_warehouse_authoritative_anchors', function (Blueprint $table): void {
|
||||
$table->uuid('generation');
|
||||
$table->string('sku_code', 191);
|
||||
$table->decimal('qty', 20, 4);
|
||||
$table->dateTime('anchor_at');
|
||||
$table->dateTime('imported_at');
|
||||
|
||||
$table->primary(
|
||||
['generation', 'sku_code'],
|
||||
'tpw_authoritative_anchors_primary',
|
||||
);
|
||||
$table->index(['generation', 'anchor_at'], 'tpw_authoritative_anchors_time_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('third_party_warehouse_authoritative_rows')) {
|
||||
$schema->create('third_party_warehouse_authoritative_rows', function (Blueprint $table): void {
|
||||
$table->uuid('generation');
|
||||
$table->date('report_date');
|
||||
$table->string('sku_code', 191);
|
||||
$table->decimal('opening_qty', 20, 4);
|
||||
$table->decimal('closing_qty', 20, 4);
|
||||
foreach ([
|
||||
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
|
||||
'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake',
|
||||
'transfer_out', 'transfer_in', 'opening_aux', 'other_out',
|
||||
] as $column) {
|
||||
$table->decimal($column, 20, 4)->default(0);
|
||||
}
|
||||
$table->dateTime('imported_at');
|
||||
|
||||
$table->primary(
|
||||
['generation', 'report_date', 'sku_code'],
|
||||
'tpw_authoritative_rows_primary',
|
||||
);
|
||||
$table->index(['generation', 'report_date'], 'tpw_authoritative_rows_date_index');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
$schema->dropIfExists('third_party_warehouse_authoritative_rows');
|
||||
$schema->dropIfExists('third_party_warehouse_authoritative_anchors');
|
||||
$schema->dropIfExists('third_party_warehouse_authoritative_anchor_states');
|
||||
}
|
||||
};
|
||||
@ -174,10 +174,12 @@ printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
|
||||
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
|
||||
|
||||
# 只运行已登记的上游后台读取和 E 签宝状态轮询,不启用整个 Laravel
|
||||
# 调度器,避免意外启动账单提醒等既有任务。不同任务使用独立锁。
|
||||
# 调度器,避免意外启动账单提醒等既有任务。上游读取共用一把锁,E 签宝单独加锁。
|
||||
# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。
|
||||
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
|
||||
CRON_TZ=Asia/Shanghai
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet >/dev/null 2>&1
|
||||
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
|
||||
EOF
|
||||
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots
|
||||
|
||||
@ -178,10 +178,12 @@ printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
|
||||
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
|
||||
|
||||
# 只运行已登记的上游后台读取和 E 签宝状态轮询,不启用整个 Laravel
|
||||
# 调度器,避免意外启动账单提醒等既有任务。不同任务使用独立锁。
|
||||
# 调度器,避免意外启动账单提醒等既有任务。上游读取共用一把锁,E 签宝单独加锁。
|
||||
# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。
|
||||
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
|
||||
CRON_TZ=Asia/Shanghai
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet >/dev/null 2>&1
|
||||
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
|
||||
EOF
|
||||
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
| --- | --- |
|
||||
| 已完成 | `operations_replica/rpt.Bulk_Price_Limit` 已进入类型化表 `product_price_mirror_rows`,商品价格页面已读取镜像并支持完整分页。 |
|
||||
| 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 |
|
||||
| 本次接入 | 聚水潭进销存“商品 + 按天”数据固定读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 中仓库 `三方-南昌樵舍仓`(`wms_co_id = 15045364`),进入专用流水、明细、库存快照及同步状态镜像。页面位于“快递应付 / 三方仓进销存”,仅展示 2026-08-01 起的数据;按月或单日选择后均输出“日期 + 商品”明细,并支持分页、筛选、数量汇总和导出。 |
|
||||
| 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 |
|
||||
| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 |
|
||||
| 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 |
|
||||
@ -58,6 +59,7 @@
|
||||
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` |
|
||||
| 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` |
|
||||
| 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` |
|
||||
| 三方仓进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(仅 `wms_co_id = 15045364`、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像) |
|
||||
| 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` |
|
||||
| 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` |
|
||||
| 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` |
|
||||
@ -83,6 +85,7 @@
|
||||
9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。
|
||||
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。
|
||||
11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。
|
||||
12. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。
|
||||
|
||||
工作平台采购价格首次全量需在备份和负责人授权后单独执行:
|
||||
|
||||
@ -90,6 +93,12 @@
|
||||
php artisan business-mirror:sync-tables jiansun.dbo.pm_priceAdjustment --full
|
||||
```
|
||||
|
||||
三方仓进销存首次全量使用专用命令显式启动;中断后重复执行同一命令会从稳定游标继续,只有流水、明细和库存三部分全部完成后才切换页面代次:
|
||||
|
||||
```bash
|
||||
php artisan business-mirror:sync-third-party-warehouse-storage --full
|
||||
```
|
||||
|
||||
## 后续切换顺序
|
||||
|
||||
1. 公司、店铺、商标等小型维表。
|
||||
|
||||
@ -0,0 +1,852 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Generator;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 三方仓进销存的共享业务镜像只读查询。
|
||||
*
|
||||
* 已导入的历史月份直接读取权威日明细;后续月份从每个 SKU 的固定
|
||||
* 月末锚点按已确认流水前推。未导入锚点时保留实时库存兼容逻辑。
|
||||
*/
|
||||
class ThirdPartyWarehouseStorageMirrorRepository
|
||||
{
|
||||
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage';
|
||||
|
||||
public const WAREHOUSE_ID = 15045364;
|
||||
|
||||
private const STATE_TABLE = 'third_party_warehouse_sync_states';
|
||||
|
||||
private const HEADER_TABLE = 'third_party_warehouse_inout_headers';
|
||||
|
||||
private const ITEM_TABLE = 'third_party_warehouse_inout_items';
|
||||
|
||||
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
|
||||
|
||||
private const AUTHORITATIVE_STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states';
|
||||
|
||||
private const AUTHORITATIVE_ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors';
|
||||
|
||||
private const AUTHORITATIVE_ROW_TABLE = 'third_party_warehouse_authoritative_rows';
|
||||
|
||||
private const AUTHORITATIVE_DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor';
|
||||
|
||||
private const PRODUCT_TABLE = 'ops_jst_sku';
|
||||
|
||||
private const GENERIC_STATE_TABLE = 'business_mirror_table_states';
|
||||
|
||||
private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku';
|
||||
|
||||
public const START_DATE = '2026-08-01';
|
||||
|
||||
/** @var array<string,string> */
|
||||
private const MOVEMENT_COLUMNS = [
|
||||
'processing_in' => '加工进仓',
|
||||
'processing_out' => '加工出仓',
|
||||
'purchase_in' => '采购进仓',
|
||||
'purchase_return' => '采购退货',
|
||||
'other_in' => '其它进仓',
|
||||
'other_return' => '其它退货',
|
||||
'sales_out' => '销售出仓',
|
||||
'sales_return' => '销售退货',
|
||||
'stocktake' => '盘点',
|
||||
'transfer_out' => '调拨出',
|
||||
'transfer_in' => '调拨入',
|
||||
'other_out' => '其它出库',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const OUTPUT_COLUMNS = [
|
||||
'owner', 'date', 'sku_code', 'image_url', 'product_name', 'short_name',
|
||||
'supplier', 'category', 'virtual_category', 'supplier_style_no',
|
||||
'color_spec', 'brand', 'unit', 'opening_qty', 'closing_qty',
|
||||
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
|
||||
'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake',
|
||||
'transfer_out', 'transfer_in', 'opening_aux', 'other_out',
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
{
|
||||
$this->connection = $databases->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
|
||||
foreach ([
|
||||
self::STATE_TABLE,
|
||||
self::HEADER_TABLE,
|
||||
self::ITEM_TABLE,
|
||||
self::INVENTORY_TABLE,
|
||||
self::PRODUCT_TABLE,
|
||||
self::GENERIC_STATE_TABLE,
|
||||
] as $table) {
|
||||
if (! $schema->hasTable($table)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $schema->hasColumn(self::ITEM_TABLE, 'qty_type')
|
||||
&& $schema->hasColumn(self::HEADER_TABLE, 'source_modified_at')
|
||||
&& $schema->hasColumn(self::ITEM_TABLE, 'source_modified_at')
|
||||
&& $schema->hasColumn(self::INVENTORY_TABLE, 'source_modified_at')
|
||||
&& $schema->hasColumn(self::INVENTORY_TABLE, 'captured_at');
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
$state = $this->state();
|
||||
|
||||
return is_array($state)
|
||||
&& ($state['initial_completed_at'] ?? null) !== null
|
||||
&& $this->generation($state, 'active_header_generation') !== null
|
||||
&& $this->generation($state, 'active_item_generation') !== null
|
||||
&& $this->generation($state, 'active_inventory_generation') !== null
|
||||
&& $this->dependenciesReady();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function state(): ?array
|
||||
{
|
||||
if (! $this->available()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$row = $this->connection->table(self::STATE_TABLE)
|
||||
->where('dataset_key', self::DATASET_KEY)
|
||||
->first();
|
||||
|
||||
return $row === null ? null : (array) $row;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $state
|
||||
* @return array{code:string,last_succeeded_at:?string,lag_minutes:?int}
|
||||
*/
|
||||
public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array
|
||||
{
|
||||
$state ??= $this->state();
|
||||
if (! is_array($state)) {
|
||||
return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
}
|
||||
|
||||
$lastSucceededAt = $this->timestamp($state['last_succeeded_at'] ?? null);
|
||||
$lastSucceeded = $lastSucceededAt === null
|
||||
? null
|
||||
: Carbon::createFromTimestamp($lastSucceededAt)->toDateTimeString();
|
||||
$lagMinutes = $lastSucceededAt === null
|
||||
? null
|
||||
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceededAt) / 60);
|
||||
$status = (string) ($state['status'] ?? 'pending');
|
||||
|
||||
if (($state['initial_completed_at'] ?? null) === null) {
|
||||
$code = $status === 'failed'
|
||||
? 'initial_failed'
|
||||
: ($status === 'running' ? 'initializing' : 'pending');
|
||||
} elseif (! $this->dependenciesReady()) {
|
||||
$code = 'dependency_pending';
|
||||
} elseif ($status === 'failed') {
|
||||
$code = 'failed';
|
||||
} elseif ($status === 'running') {
|
||||
$code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing';
|
||||
} elseif ($lagMinutes === null
|
||||
|| $lagMinutes > max((int) config(
|
||||
'upstream_mirrors.third_party_warehouse_storage.stale_after_minutes',
|
||||
1560,
|
||||
), 1)) {
|
||||
$code = 'delayed';
|
||||
} else {
|
||||
$code = 'healthy';
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'last_succeeded_at' => $lastSucceeded,
|
||||
'lag_minutes' => $lagMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{data:list<array<string,mixed>>,total:int,summary:array<string,int|float>,state:array<string,mixed>,sync:array<string,mixed>}
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$state = $this->requireReadyState();
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$query = $this->filteredReportQuery($filters, $state);
|
||||
$summary = $this->summarizeQuery($this->filteredReportQuery(
|
||||
$filters,
|
||||
$state,
|
||||
$this->hasProductFilters($filters),
|
||||
));
|
||||
$rows = $query->orderBy('report_date')->orderBy('sku_code')
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->mapRow($row))
|
||||
->all();
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $summary['count'],
|
||||
'summary' => $summary,
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{count:int,opening_qty:float,closing_qty:float}
|
||||
*/
|
||||
public function summary(array $filters): array
|
||||
{
|
||||
$state = $this->requireReadyState();
|
||||
|
||||
return $this->summarizeQuery($this->filteredReportQuery(
|
||||
$filters,
|
||||
$state,
|
||||
$this->hasProductFilters($filters),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{suppliers:list<string>,categories:list<string>}
|
||||
*/
|
||||
public function filterOptions(array $filters = []): array
|
||||
{
|
||||
$state = $this->requireReadyState();
|
||||
$query = $this->filteredReportQuery($filters, $state);
|
||||
|
||||
$suppliers = (clone $query)->whereNotNull('supplier')->where('supplier', '!=', '')
|
||||
->distinct()->orderBy('supplier')->pluck('supplier')
|
||||
->map(static fn (mixed $value): string => (string) $value)->all();
|
||||
$categories = (clone $query)->whereNotNull('category')->where('category', '!=', '')
|
||||
->distinct()->orderBy('category')->pluck('category')
|
||||
->map(static fn (mixed $value): string => (string) $value)->all();
|
||||
|
||||
return ['suppliers' => $suppliers, 'categories' => $categories];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $filters
|
||||
* @return Generator<int,array<string,mixed>>
|
||||
*/
|
||||
public function exportRows(array $filters, int $batchSize = 500): Generator
|
||||
{
|
||||
$state = $this->requireReadyState();
|
||||
$batchSize = min(max($batchSize, 1), 5000);
|
||||
$lastDate = null;
|
||||
$lastSku = null;
|
||||
|
||||
while (true) {
|
||||
$query = $this->filteredReportQuery($filters, $state);
|
||||
if ($lastDate !== null && $lastSku !== null) {
|
||||
$query->where(function (Builder $cursor) use ($lastDate, $lastSku): void {
|
||||
$cursor->where('report_date', '>', $lastDate)
|
||||
->orWhere(function (Builder $sameDate) use ($lastDate, $lastSku): void {
|
||||
$sameDate->where('report_date', $lastDate)
|
||||
->where('sku_code', '>', $lastSku);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$rows = $query->orderBy('report_date')->orderBy('sku_code')->limit($batchSize)->get();
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
yield $this->mapRow($row);
|
||||
}
|
||||
|
||||
$lastDate = (string) $rows->last()->report_date;
|
||||
$lastSku = (string) $rows->last()->sku_code;
|
||||
if ($rows->count() < $batchSize) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function filteredReportQuery(
|
||||
array $filters,
|
||||
array $state,
|
||||
bool $includeProductDetails = true,
|
||||
): Builder
|
||||
{
|
||||
$query = $this->connection->query()->fromSub(
|
||||
$this->reportQuery($filters, $state, $includeProductDetails),
|
||||
'report',
|
||||
);
|
||||
|
||||
$this->containsFilter($query, 'sku_code', $filters['sku_code'] ?? '');
|
||||
$this->containsFilter($query, 'product_name', $filters['product_name'] ?? '');
|
||||
$this->containsFilter($query, 'supplier', $filters['supplier'] ?? '');
|
||||
$this->containsFilter($query, 'category', $filters['category'] ?? '');
|
||||
|
||||
$nonZero = ['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS)];
|
||||
$query->where(function (Builder $values) use ($nonZero): void {
|
||||
foreach ($nonZero as $index => $column) {
|
||||
$method = $index === 0 ? 'where' : 'orWhere';
|
||||
$values->{$method}($column, '<>', 0);
|
||||
}
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function reportQuery(
|
||||
array $filters,
|
||||
array $state,
|
||||
bool $includeProductDetails,
|
||||
): Builder
|
||||
{
|
||||
$period = $this->period($filters);
|
||||
$headerGeneration = $this->generation($state, 'active_header_generation');
|
||||
$itemGeneration = $this->generation($state, 'active_item_generation');
|
||||
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
|
||||
$authoritativeAnchor = $this->authoritativeAnchor();
|
||||
$anchorAt = $authoritativeAnchor['anchor_at'] ?? $this->stateAnchor($state);
|
||||
|
||||
if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) {
|
||||
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
|
||||
}
|
||||
|
||||
$authoritativeRows = $this->authoritativeReportQuery(
|
||||
$period,
|
||||
$authoritativeAnchor,
|
||||
$includeProductDetails,
|
||||
);
|
||||
if ($authoritativeRows !== null) {
|
||||
return $authoritativeRows;
|
||||
}
|
||||
|
||||
$skuRangeStart = min($period['start'], $anchorAt);
|
||||
$skuRangeEnd = max($period['end'], $anchorAt);
|
||||
$movementSkus = $this->confirmedMovements($headerGeneration, $itemGeneration)
|
||||
->where('h.occurred_at', '>=', $skuRangeStart)
|
||||
->where('h.occurred_at', '<', $skuRangeEnd)
|
||||
->select('i.sku_code');
|
||||
if ($authoritativeAnchor !== null) {
|
||||
$skuUnion = $this->connection->table(self::AUTHORITATIVE_ANCHOR_TABLE . ' as inventory_skus')
|
||||
->where('inventory_skus.generation', $authoritativeAnchor['generation'])
|
||||
->whereNotNull('inventory_skus.sku_code')
|
||||
->where('inventory_skus.sku_code', '!=', '')
|
||||
->select('inventory_skus.sku_code')
|
||||
->union($movementSkus);
|
||||
$anchors = $this->connection->query()->fromSub($skuUnion, 'sku_set')
|
||||
->leftJoin(self::AUTHORITATIVE_ANCHOR_TABLE . ' as inventory', function ($join) use ($authoritativeAnchor): void {
|
||||
$join->on('inventory.sku_code', '=', 'sku_set.sku_code')
|
||||
->where('inventory.generation', '=', $authoritativeAnchor['generation']);
|
||||
})
|
||||
->select('sku_set.sku_code')
|
||||
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
|
||||
->selectRaw('COALESCE(inventory.anchor_at, ?) AS snapshot_at', [$anchorAt]);
|
||||
} else {
|
||||
$skuUnion = $this->connection->table(self::INVENTORY_TABLE . ' as inventory_skus')
|
||||
->where('inventory_skus.generation', $inventoryGeneration)
|
||||
->where('inventory_skus.wms_co_id', self::WAREHOUSE_ID)
|
||||
->whereNotNull('inventory_skus.sku_code')
|
||||
->where('inventory_skus.sku_code', '!=', '')
|
||||
->select('inventory_skus.sku_code')
|
||||
->union($movementSkus);
|
||||
$anchors = $this->connection->query()->fromSub($skuUnion, 'sku_set')
|
||||
->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void {
|
||||
$join->on('inventory.sku_code', '=', 'sku_set.sku_code')
|
||||
->where('inventory.generation', '=', $inventoryGeneration)
|
||||
->where('inventory.wms_co_id', '=', self::WAREHOUSE_ID);
|
||||
})
|
||||
->select('sku_set.sku_code')
|
||||
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
|
||||
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
|
||||
}
|
||||
|
||||
$dateSkuAnchors = $this->connection->query()
|
||||
->fromSub($this->dateSeries($period['start'], $period['end']), 'd')
|
||||
->crossJoinSub($anchors, 'a')
|
||||
->select([
|
||||
'd.report_date',
|
||||
'd.day_start',
|
||||
'd.day_end',
|
||||
'a.sku_code',
|
||||
'a.snapshot_qty',
|
||||
'a.snapshot_at',
|
||||
]);
|
||||
$signedQuantity = '(COALESCE(movement_qty, 0) * COALESCE(movement_qty_type, 0))';
|
||||
$forwardAnchorOperator = $authoritativeAnchor === null ? '>' : '>=';
|
||||
$closingExpression = 'da.snapshot_qty + COALESCE(SUM(CASE '
|
||||
. 'WHEN da.snapshot_at < da.day_end AND mv.occurred_at ' . $forwardAnchorOperator . ' da.snapshot_at '
|
||||
. 'AND mv.occurred_at < da.day_end THEN ' . $signedQuantity . ' '
|
||||
. 'WHEN da.snapshot_at >= da.day_end AND mv.occurred_at >= da.day_end '
|
||||
. 'AND mv.occurred_at < da.snapshot_at THEN -' . $signedQuantity . ' '
|
||||
. 'ELSE 0 END), 0)';
|
||||
$allMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
|
||||
->where('h.occurred_at', '>=', $skuRangeStart)
|
||||
->where('h.occurred_at', '<', $skuRangeEnd)
|
||||
->select([
|
||||
'i.sku_code',
|
||||
'i.qty as movement_qty',
|
||||
'i.qty_type as movement_qty_type',
|
||||
'h.occurred_at',
|
||||
]);
|
||||
$balances = $this->connection->query()->fromSub($dateSkuAnchors, 'da')
|
||||
->leftJoinSub($allMovements, 'mv', 'mv.sku_code', '=', 'da.sku_code')
|
||||
->groupBy(
|
||||
'da.report_date',
|
||||
'da.day_start',
|
||||
'da.day_end',
|
||||
'da.sku_code',
|
||||
'da.snapshot_qty',
|
||||
'da.snapshot_at',
|
||||
)
|
||||
->select(['da.report_date', 'da.sku_code'])
|
||||
->selectRaw($closingExpression . ' AS closing_qty');
|
||||
|
||||
$periodMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
|
||||
->where('h.occurred_at', '>=', $period['start'])
|
||||
->where('h.occurred_at', '<', $period['end'])
|
||||
->groupBy('i.sku_code')
|
||||
->groupByRaw('DATE(h.occurred_at)')
|
||||
->select('i.sku_code')
|
||||
->selectRaw('DATE(h.occurred_at) AS report_date');
|
||||
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
|
||||
$periodMovements->selectRaw(
|
||||
'COALESCE(SUM(CASE WHEN h.io_type = ? THEN '
|
||||
. '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0)) ELSE 0 END), 0) AS ' . $column,
|
||||
[$type],
|
||||
);
|
||||
}
|
||||
|
||||
$report = $this->connection->query()->fromSub($balances, 'b')
|
||||
->leftJoinSub($periodMovements, 'm', function ($join): void {
|
||||
$join->on('m.sku_code', '=', 'b.sku_code')
|
||||
->on('m.report_date', '=', 'b.report_date');
|
||||
});
|
||||
if ($includeProductDetails) {
|
||||
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
|
||||
->where('sku_type', 'normal')
|
||||
->whereNotNull('sku_id')->where('sku_id', '!=', '')
|
||||
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
|
||||
$report->leftJoinSub(
|
||||
$latestProducts,
|
||||
'latest_product',
|
||||
'latest_product.sku_id',
|
||||
'=',
|
||||
'b.sku_code',
|
||||
)->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
|
||||
$join->on('s.sku_id', '=', 'latest_product.sku_id')
|
||||
->on('s.autoid', '=', 'latest_product.autoid')
|
||||
->where('s.sku_type', '=', 'normal');
|
||||
});
|
||||
}
|
||||
|
||||
$movementSelects = [];
|
||||
foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) {
|
||||
$movementSelects[$column] = 'COALESCE(m.' . $column . ', 0)';
|
||||
}
|
||||
$netMovement = implode(' + ', array_values($movementSelects));
|
||||
$openingQuantity = '(b.closing_qty - (' . $netMovement . '))';
|
||||
$owner = (string) config(
|
||||
'upstream_mirrors.third_party_warehouse_storage.owner_name',
|
||||
'常州简尚家居有限公司',
|
||||
);
|
||||
|
||||
$report->selectRaw('? AS owner', [$owner])
|
||||
->addSelect('b.report_date')
|
||||
->addSelect('b.sku_code');
|
||||
if ($includeProductDetails) {
|
||||
$report->addSelect([
|
||||
$this->connection->raw('COALESCE(s.pic_big, s.pic) AS image_url'),
|
||||
$this->connection->raw('s.name AS product_name'),
|
||||
$this->connection->raw('COALESCE(s.short_name, s.name) AS short_name'),
|
||||
$this->connection->raw('s.supplier_name AS supplier'),
|
||||
$this->connection->raw('s.category AS category'),
|
||||
$this->connection->raw('s.vc_name AS virtual_category'),
|
||||
$this->connection->raw('s.supplier_sku_id AS supplier_style_no'),
|
||||
$this->connection->raw('COALESCE(s.properties_value, s.color) AS color_spec'),
|
||||
$this->connection->raw('s.brand AS brand'),
|
||||
$this->connection->raw('s.unit AS unit'),
|
||||
]);
|
||||
}
|
||||
$report->selectRaw($openingQuantity . ' AS opening_qty')
|
||||
->selectRaw('b.closing_qty AS closing_qty');
|
||||
foreach ($movementSelects as $column => $expression) {
|
||||
$report->selectRaw($expression . ' AS ' . $column);
|
||||
}
|
||||
|
||||
$report->selectRaw('0 AS opening_aux');
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
private function dateSeries(string $start, string $end): Builder
|
||||
{
|
||||
$cursor = Carbon::parse($start)->startOfDay();
|
||||
$through = Carbon::parse($end)->startOfDay();
|
||||
$dates = null;
|
||||
|
||||
while ($cursor < $through) {
|
||||
$dayStart = $cursor->copy();
|
||||
$dayEnd = $cursor->copy()->addDay();
|
||||
$row = $this->connection->query()->selectRaw(
|
||||
'? AS report_date, ? AS day_start, ? AS day_end',
|
||||
[$dayStart->toDateString(), $dayStart->toDateTimeString(), $dayEnd->toDateTimeString()],
|
||||
);
|
||||
$dates = $dates === null ? $row : $dates->unionAll($row);
|
||||
$cursor->addDay();
|
||||
}
|
||||
|
||||
if (! $dates instanceof Builder) {
|
||||
throw new RuntimeException('third_party_warehouse_period_invalid');
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder
|
||||
{
|
||||
return $this->connection->table(self::ITEM_TABLE . ' as i')
|
||||
->join(self::HEADER_TABLE . ' as h', function ($join) use ($headerGeneration): void {
|
||||
$join->on('h.io_id', '=', 'i.io_id')
|
||||
->where('h.generation', '=', $headerGeneration);
|
||||
})
|
||||
->where('i.generation', $itemGeneration)
|
||||
->where('h.wms_co_id', self::WAREHOUSE_ID)
|
||||
->where('h.status', 'Confirmed')
|
||||
->whereIn('h.io_type', array_values(self::MOVEMENT_COLUMNS))
|
||||
->whereNotExists(function (Builder $newer) use ($headerGeneration): void {
|
||||
$newer->selectRaw('1')->from(self::HEADER_TABLE . ' as newer')
|
||||
->where('newer.generation', $headerGeneration)
|
||||
->whereColumn('newer.io_id', 'h.io_id')
|
||||
->whereColumn('newer.id', '>', 'h.id');
|
||||
})
|
||||
->whereNotNull('i.sku_code')->where('i.sku_code', '!=', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{dimension:string,value:string,start:string,end:string,days:int}
|
||||
*/
|
||||
private function period(array $filters): array
|
||||
{
|
||||
$dimension = ($filters['dimension'] ?? 'month') === 'day' ? 'day' : 'month';
|
||||
$candidate = trim((string) ($filters['period'] ?? ''));
|
||||
|
||||
if ($dimension === 'day') {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) !== 1) {
|
||||
$candidate = trim((string) ($filters['date'] ?? ''));
|
||||
}
|
||||
try {
|
||||
$start = preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) === 1
|
||||
? Carbon::createFromFormat('!Y-m-d', $candidate)
|
||||
: now()->startOfDay();
|
||||
} catch (Throwable) {
|
||||
$start = now()->startOfDay();
|
||||
}
|
||||
if ($start->lt(Carbon::parse(self::START_DATE)->startOfDay())) {
|
||||
$start = Carbon::parse(self::START_DATE)->startOfDay();
|
||||
}
|
||||
|
||||
return [
|
||||
'dimension' => 'day',
|
||||
'value' => $start->format('Y-m-d'),
|
||||
'start' => $start->format('Y-m-d 00:00:00'),
|
||||
'end' => $start->copy()->addDay()->format('Y-m-d 00:00:00'),
|
||||
'days' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $candidate) !== 1) {
|
||||
$candidate = trim((string) ($filters['month'] ?? ''));
|
||||
}
|
||||
try {
|
||||
$start = preg_match('/^\d{4}-\d{2}$/', $candidate) === 1
|
||||
? Carbon::createFromFormat('!Y-m', $candidate)->startOfMonth()
|
||||
: now()->startOfMonth();
|
||||
} catch (Throwable) {
|
||||
$start = now()->startOfMonth();
|
||||
}
|
||||
$minimum = Carbon::parse(self::START_DATE)->startOfMonth();
|
||||
if ($start->lt($minimum)) {
|
||||
$start = $minimum;
|
||||
}
|
||||
|
||||
return [
|
||||
'dimension' => 'month',
|
||||
'value' => $start->format('Y-m'),
|
||||
'start' => $start->format('Y-m-d 00:00:00'),
|
||||
'end' => $start->copy()->addMonth()->format('Y-m-d 00:00:00'),
|
||||
'days' => $start->daysInMonth,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function stateAnchor(array $state): string
|
||||
{
|
||||
$timestamp = $this->timestamp($state['last_succeeded_at'] ?? null);
|
||||
|
||||
return $timestamp === null
|
||||
? now()->toDateTimeString()
|
||||
: Carbon::createFromTimestamp($timestamp)->toDateTimeString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{generation:string,anchor_at:string,source_period_start:?string,source_period_end:?string}|null $authoritativeAnchor
|
||||
*/
|
||||
private function authoritativeReportQuery(
|
||||
array $period,
|
||||
?array $authoritativeAnchor,
|
||||
bool $includeProductDetails,
|
||||
): ?Builder {
|
||||
if ($authoritativeAnchor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
if (! $schema->hasTable(self::AUTHORITATIVE_ROW_TABLE)) {
|
||||
return null;
|
||||
}
|
||||
$sourceStart = $authoritativeAnchor['source_period_start'] ?? null;
|
||||
$sourceEnd = $authoritativeAnchor['source_period_end'] ?? null;
|
||||
if (! is_string($sourceStart) || ! is_string($sourceEnd)
|
||||
|| $sourceStart === '' || $sourceEnd === '') {
|
||||
return null;
|
||||
}
|
||||
$sourceStartAt = Carbon::parse($sourceStart)->startOfDay();
|
||||
$sourceEndAt = Carbon::parse($sourceEnd)->addDay()->startOfDay();
|
||||
$periodStartAt = Carbon::parse($period['start']);
|
||||
$periodEndAt = Carbon::parse($period['end']);
|
||||
if ($periodStartAt->lt($sourceStartAt) || $periodEndAt->gt($sourceEndAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = $this->connection->table(self::AUTHORITATIVE_ROW_TABLE . ' as b')
|
||||
->where('b.generation', $authoritativeAnchor['generation'])
|
||||
->where('b.report_date', '>=', $periodStartAt->toDateString())
|
||||
->where('b.report_date', '<', $periodEndAt->toDateString());
|
||||
$owner = (string) config(
|
||||
'upstream_mirrors.third_party_warehouse_storage.owner_name',
|
||||
'常州简尚家居有限公司',
|
||||
);
|
||||
$rows->selectRaw('? AS owner', [$owner])->addSelect(['b.report_date', 'b.sku_code']);
|
||||
if ($includeProductDetails) {
|
||||
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
|
||||
->where('sku_type', 'normal')
|
||||
->whereNotNull('sku_id')->where('sku_id', '!=', '')
|
||||
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
|
||||
$rows->leftJoinSub(
|
||||
$latestProducts,
|
||||
'latest_product',
|
||||
'latest_product.sku_id',
|
||||
'=',
|
||||
'b.sku_code',
|
||||
)->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
|
||||
$join->on('s.sku_id', '=', 'latest_product.sku_id')
|
||||
->on('s.autoid', '=', 'latest_product.autoid')
|
||||
->where('s.sku_type', '=', 'normal');
|
||||
})->addSelect([
|
||||
$this->connection->raw('COALESCE(s.pic_big, s.pic) AS image_url'),
|
||||
$this->connection->raw('s.name AS product_name'),
|
||||
$this->connection->raw('COALESCE(s.short_name, s.name) AS short_name'),
|
||||
$this->connection->raw('s.supplier_name AS supplier'),
|
||||
$this->connection->raw('s.category AS category'),
|
||||
$this->connection->raw('s.vc_name AS virtual_category'),
|
||||
$this->connection->raw('s.supplier_sku_id AS supplier_style_no'),
|
||||
$this->connection->raw('COALESCE(s.properties_value, s.color) AS color_spec'),
|
||||
$this->connection->raw('s.brand AS brand'),
|
||||
$this->connection->raw('s.unit AS unit'),
|
||||
]);
|
||||
}
|
||||
$rows->addSelect([
|
||||
'b.opening_qty', 'b.closing_qty', 'b.processing_in', 'b.processing_out',
|
||||
'b.purchase_in', 'b.purchase_return', 'b.other_in', 'b.other_return',
|
||||
'b.sales_out', 'b.sales_return', 'b.stocktake', 'b.transfer_out',
|
||||
'b.transfer_in', 'b.opening_aux', 'b.other_out',
|
||||
]);
|
||||
|
||||
return $rows;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{generation:string,anchor_at:string,source_period_start:?string,source_period_end:?string}|null */
|
||||
private function authoritativeAnchor(): ?array
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
if (! $schema->hasTable(self::AUTHORITATIVE_STATE_TABLE)
|
||||
|| ! $schema->hasTable(self::AUTHORITATIVE_ANCHOR_TABLE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = $this->connection->table(self::AUTHORITATIVE_STATE_TABLE)
|
||||
->where('dataset_key', self::AUTHORITATIVE_DATASET_KEY)
|
||||
->whereNotNull('active_generation')
|
||||
->whereNotNull('anchor_at')
|
||||
->first();
|
||||
if ($state === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$generation = trim((string) $state->active_generation);
|
||||
$anchorAt = $this->timestamp($state->anchor_at ?? null);
|
||||
if ($generation === '' || $anchorAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'generation' => $generation,
|
||||
'anchor_at' => Carbon::createFromTimestamp($anchorAt)->toDateTimeString(),
|
||||
'source_period_start' => isset($state->source_period_start)
|
||||
? (string) $state->source_period_start : null,
|
||||
'source_period_end' => isset($state->source_period_end)
|
||||
? (string) $state->source_period_end : null,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function requireReadyState(): array
|
||||
{
|
||||
$state = $this->state();
|
||||
if (! is_array($state)
|
||||
|| ($state['initial_completed_at'] ?? null) === null
|
||||
|| $this->generation($state, 'active_header_generation') === null
|
||||
|| $this->generation($state, 'active_item_generation') === null
|
||||
|| $this->generation($state, 'active_inventory_generation') === null
|
||||
|| ! $this->dependenciesReady()) {
|
||||
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function dependenciesReady(): bool
|
||||
{
|
||||
try {
|
||||
$required = [self::PRODUCT_DATASET_KEY];
|
||||
$ready = $this->connection->table(self::GENERIC_STATE_TABLE)
|
||||
->whereIn('dataset_key', $required)
|
||||
->whereNotNull('initial_completed_at')
|
||||
->pluck('dataset_key')
|
||||
->map(static fn (mixed $dataset): string => (string) $dataset)
|
||||
->all();
|
||||
|
||||
return array_diff($required, $ready) === [];
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function generation(array $state, string $key): ?string
|
||||
{
|
||||
$value = trim((string) ($state[$key] ?? ''));
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): ?int
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->getTimestamp();
|
||||
}
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->getTimestamp();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{count:int,opening_qty:float,closing_qty:float}
|
||||
*/
|
||||
private function summarizeQuery(Builder $query): array
|
||||
{
|
||||
$row = $query->selectRaw(
|
||||
'COUNT(*) AS row_count, '
|
||||
. 'COALESCE(SUM(opening_qty), 0) AS opening_qty, '
|
||||
. 'COALESCE(SUM(closing_qty), 0) AS closing_qty'
|
||||
)->first();
|
||||
|
||||
return [
|
||||
'count' => (int) ($row->row_count ?? 0),
|
||||
'opening_qty' => round((float) ($row->opening_qty ?? 0), 4),
|
||||
'closing_qty' => round((float) ($row->closing_qty ?? 0), 4),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapRow(object $row): array
|
||||
{
|
||||
$mapped = [];
|
||||
foreach (self::OUTPUT_COLUMNS as $column) {
|
||||
$mapped[$column] = $column === 'date'
|
||||
? ($row->report_date ?? null)
|
||||
: ($row->{$column} ?? null);
|
||||
}
|
||||
foreach (['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS), 'opening_aux'] as $column) {
|
||||
$mapped[$column] = round((float) ($mapped[$column] ?? 0), 4);
|
||||
}
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function containsFilter(Builder $query, string $column, mixed $value): void
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value !== '') {
|
||||
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters */
|
||||
private function hasProductFilters(array $filters): bool
|
||||
{
|
||||
foreach (['product_name', 'supplier', 'category'] as $filter) {
|
||||
if (trim((string) ($filters[$filter] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function contains(string $value): string
|
||||
{
|
||||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
|
||||
class ThirdPartyWarehouseStorageUpstreamRepository
|
||||
{
|
||||
public const SOURCE_KEY = 'jiansun';
|
||||
|
||||
public const WAREHOUSE_ID = 15045364;
|
||||
|
||||
public const START_DATE = '2026-08-01';
|
||||
|
||||
private ?UpstreamReadOnlyQuery $query = null;
|
||||
|
||||
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
|
||||
{
|
||||
}
|
||||
|
||||
public function headerHighWatermark(): int
|
||||
{
|
||||
return (int) $this->query()->scalar(
|
||||
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater] WHERE [wms_co_id] = ?',
|
||||
[self::WAREHOUSE_ID],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function headerRange(int $afterId, int $throughId): array
|
||||
{
|
||||
if ($throughId <= $afterId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->query()->select(<<<'SQL'
|
||||
SELECT
|
||||
h.[Id] AS source_id,
|
||||
h.[io_id],
|
||||
h.[wms_co_id],
|
||||
h.[status],
|
||||
h.[type] AS io_type,
|
||||
CONVERT(varchar(27), h.[io_date], 126) AS occurred_at,
|
||||
CONVERT(varchar(27), h.[modified], 126) AS modified_at
|
||||
FROM [dbo].[jst_inoutWater] h
|
||||
WHERE h.[Id] > ?
|
||||
AND h.[Id] <= ?
|
||||
AND h.[wms_co_id] = ?
|
||||
AND h.[io_date] >= ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM [dbo].[jst_inoutWater] newer
|
||||
WHERE newer.[io_id] = h.[io_id] AND newer.[Id] > h.[Id]
|
||||
)
|
||||
ORDER BY h.[Id]
|
||||
SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function modifiedHeadersBatch(
|
||||
string $cursorAt,
|
||||
int $cursorId,
|
||||
string $throughAt,
|
||||
int $limit,
|
||||
): array {
|
||||
$limit = min(max($limit, 1), 5000);
|
||||
|
||||
return $this->query()->select('SELECT TOP (' . $limit . ') '
|
||||
. <<<'SQL'
|
||||
[Id] AS source_id,
|
||||
[io_id],
|
||||
[wms_co_id],
|
||||
[status],
|
||||
[type] AS io_type,
|
||||
CONVERT(varchar(27), [io_date], 126) AS occurred_at,
|
||||
CONVERT(varchar(27), [modified], 126) AS modified_at
|
||||
FROM [dbo].[jst_inoutWater]
|
||||
WHERE [modified] >= ?
|
||||
AND [modified] < ?
|
||||
AND ([modified] > ? OR ([modified] = ? AND [Id] > ?))
|
||||
ORDER BY [modified], [Id]
|
||||
SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the current version for every changed document before replacing
|
||||
* its mirror rows. A document can have more than one historical header Id.
|
||||
*
|
||||
* @param list<int|string> $ioIds
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function latestHeadersForIoIds(array $ioIds, string $throughAt): array
|
||||
{
|
||||
$ioIds = array_values(array_unique(array_filter(
|
||||
array_map(static fn (mixed $id): int => (int) $id, $ioIds),
|
||||
static fn (int $id): bool => $id > 0,
|
||||
)));
|
||||
if ($ioIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (array_chunk($ioIds, 500) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$batch = $this->query()->select(
|
||||
'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[status], '
|
||||
. 'h.[type] AS io_type, CONVERT(varchar(27), h.[io_date], 126) AS occurred_at, '
|
||||
. 'CONVERT(varchar(27), h.[modified], 126) AS modified_at '
|
||||
. 'FROM [dbo].[jst_inoutWater] h '
|
||||
. 'INNER JOIN (SELECT [io_id], MAX([Id]) AS latest_id '
|
||||
. 'FROM [dbo].[jst_inoutWater] '
|
||||
. "WHERE [io_id] IN ({$placeholders}) AND [modified] < ? GROUP BY [io_id]) latest "
|
||||
. 'ON latest.[latest_id] = h.[Id] '
|
||||
. 'WHERE h.[io_date] >= ? '
|
||||
. 'ORDER BY h.[Id]',
|
||||
[...$chunk, $throughAt, self::START_DATE],
|
||||
);
|
||||
array_push($rows, ...$batch);
|
||||
}
|
||||
|
||||
usort($rows, static fn (array $left, array $right): int =>
|
||||
((int) ($left['source_id'] ?? 0)) <=> ((int) ($right['source_id'] ?? 0))
|
||||
);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream item table has an index on io_id. Fetching items only after
|
||||
* the warehouse headers have been selected avoids scanning unrelated rows.
|
||||
*
|
||||
* @param list<int|string> $ioIds
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function itemsForIoIds(array $ioIds): array
|
||||
{
|
||||
$ioIds = array_values(array_unique(array_filter(
|
||||
array_map(static fn (mixed $id): int => (int) $id, $ioIds),
|
||||
static fn (int $id): bool => $id > 0,
|
||||
)));
|
||||
|
||||
if ($ioIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (array_chunk($ioIds, 500) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$batch = $this->query()->select(
|
||||
'SELECT [Id] AS source_id, [io_id], [sku_id] AS sku_code, [qty], [qty_type] '
|
||||
. 'FROM [dbo].[jst_inoutWaterItem] '
|
||||
. "WHERE [io_id] IN ({$placeholders}) ORDER BY [Id]",
|
||||
$chunk,
|
||||
);
|
||||
array_push($rows, ...$batch);
|
||||
}
|
||||
|
||||
usort($rows, static fn (array $left, array $right): int =>
|
||||
((int) ($left['source_id'] ?? 0)) <=> ((int) ($right['source_id'] ?? 0))
|
||||
);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function inventoryHighWatermark(): int
|
||||
{
|
||||
return (int) $this->query()->scalar(
|
||||
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo] WHERE [wms_co_id] = ?',
|
||||
[self::WAREHOUSE_ID],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function inventoryBatch(int $afterId, int $highWatermark, int $limit): array
|
||||
{
|
||||
if ($highWatermark <= $afterId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = min(max($limit, 1), 5000);
|
||||
|
||||
return $this->query()->select('SELECT TOP (' . $limit . ') '
|
||||
. <<<'SQL'
|
||||
[Id] AS source_id,
|
||||
[wms_co_id],
|
||||
[sku_id] AS sku_code,
|
||||
[qty],
|
||||
CONVERT(varchar(27), [modified], 126) AS source_modified_at
|
||||
FROM [dbo].[jst_inventoryInfo]
|
||||
WHERE [Id] > ?
|
||||
AND [Id] <= ?
|
||||
AND [wms_co_id] = ?
|
||||
ORDER BY [Id]
|
||||
SQL, [$afterId, $highWatermark, self::WAREHOUSE_ID]);
|
||||
}
|
||||
|
||||
private function query(): UpstreamReadOnlyQuery
|
||||
{
|
||||
return $this->query ??= $this->queries->forSource(self::SOURCE_KEY);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,501 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
use SimpleXMLElement;
|
||||
use Throwable;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
class ThirdPartyWarehouseStorageAuthoritativeAnchorImporter
|
||||
{
|
||||
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor';
|
||||
|
||||
private const STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states';
|
||||
|
||||
private const ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors';
|
||||
|
||||
private const ROW_TABLE = 'third_party_warehouse_authoritative_rows';
|
||||
|
||||
/** @var list<string> */
|
||||
private const REQUIRED_HEADERS = [
|
||||
'日期', '商品编码', '期初数量', '期末数量', '加工进仓', '加工出仓',
|
||||
'采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓',
|
||||
'销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const MOVEMENT_HEADERS = [
|
||||
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓',
|
||||
'其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入',
|
||||
'期初', '其它出库',
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
{
|
||||
$this->connection = $databases->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:string,source_rows:int,anchor_skus:int,anchor_at:string,generation:string}
|
||||
*/
|
||||
public function import(string $path): array
|
||||
{
|
||||
$path = $this->validatedPath($path);
|
||||
$this->assertSchemaReady();
|
||||
|
||||
$generation = (string) Str::uuid();
|
||||
$now = now();
|
||||
$hash = hash_file('sha256', $path);
|
||||
if (! is_string($hash)) {
|
||||
throw new RuntimeException('无法计算进销存导出文件摘要。');
|
||||
}
|
||||
|
||||
$this->connection->table(self::STATE_TABLE)->insertOrIgnore([
|
||||
'dataset_key' => self::DATASET_KEY,
|
||||
'status' => 'pending',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->connection->table(self::STATE_TABLE)
|
||||
->where('dataset_key', self::DATASET_KEY)
|
||||
->update([
|
||||
'status' => 'running',
|
||||
'pending_generation' => $generation,
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
try {
|
||||
$source = $this->readSource($path);
|
||||
$anchorAt = Carbon::parse($source['period_end'])->addDay()->startOfDay();
|
||||
$anchors = array_map(
|
||||
static fn (array $anchor): array => [
|
||||
'generation' => $generation,
|
||||
'sku_code' => $anchor['sku_code'],
|
||||
'qty' => $anchor['qty'],
|
||||
'anchor_at' => $anchorAt,
|
||||
'imported_at' => $now,
|
||||
],
|
||||
$source['anchors'],
|
||||
);
|
||||
|
||||
$authoritativeRows = array_map(
|
||||
static fn (array $row): array => $row + [
|
||||
'generation' => $generation,
|
||||
'imported_at' => $now,
|
||||
],
|
||||
$source['rows'],
|
||||
);
|
||||
|
||||
foreach (array_chunk($authoritativeRows, 500) as $batch) {
|
||||
$this->connection->table(self::ROW_TABLE)->insert($batch);
|
||||
}
|
||||
|
||||
foreach (array_chunk($anchors, 1000) as $batch) {
|
||||
$this->connection->table(self::ANCHOR_TABLE)->insert($batch);
|
||||
}
|
||||
|
||||
$this->connection->transaction(function () use (
|
||||
$generation,
|
||||
$path,
|
||||
$hash,
|
||||
$source,
|
||||
$anchorAt,
|
||||
$now,
|
||||
): void {
|
||||
$this->connection->table(self::STATE_TABLE)
|
||||
->where('dataset_key', self::DATASET_KEY)
|
||||
->update([
|
||||
'status' => 'succeeded',
|
||||
'active_generation' => $generation,
|
||||
'pending_generation' => null,
|
||||
'anchor_at' => $anchorAt,
|
||||
'source_filename' => mb_substr(basename($path), 0, 255),
|
||||
'source_sha256' => $hash,
|
||||
'source_period_start' => $source['period_start'],
|
||||
'source_period_end' => $source['period_end'],
|
||||
'source_row_count' => $source['row_count'],
|
||||
'anchor_sku_count' => count($source['anchors']),
|
||||
'last_succeeded_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
$this->connection->table(self::ANCHOR_TABLE)
|
||||
->where('generation', $generation)
|
||||
->delete();
|
||||
$this->connection->table(self::ROW_TABLE)
|
||||
->where('generation', $generation)
|
||||
->delete();
|
||||
$this->connection->table(self::STATE_TABLE)
|
||||
->where('dataset_key', self::DATASET_KEY)
|
||||
->update([
|
||||
'status' => 'failed',
|
||||
'pending_generation' => null,
|
||||
'last_error_code' => class_basename($exception),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->connection->table(self::ANCHOR_TABLE)
|
||||
->where('generation', '!=', $generation)
|
||||
->delete();
|
||||
$this->connection->table(self::ROW_TABLE)
|
||||
->where('generation', '!=', $generation)
|
||||
->delete();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'succeeded',
|
||||
'source_rows' => $source['row_count'],
|
||||
'anchor_skus' => count($source['anchors']),
|
||||
'anchor_at' => $anchorAt->toDateTimeString(),
|
||||
'generation' => $generation,
|
||||
];
|
||||
}
|
||||
|
||||
private function validatedPath(string $path): string
|
||||
{
|
||||
$realPath = realpath($path);
|
||||
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
|
||||
throw new InvalidArgumentException('三方仓进销存导出文件不存在或不可读。');
|
||||
}
|
||||
if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') {
|
||||
throw new InvalidArgumentException('三方仓权威进销存导入只支持 XLSX 文件。');
|
||||
}
|
||||
|
||||
return $realPath;
|
||||
}
|
||||
|
||||
private function assertSchemaReady(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
foreach ([self::STATE_TABLE, self::ANCHOR_TABLE, self::ROW_TABLE] as $table) {
|
||||
if (! $schema->hasTable($table)) {
|
||||
throw new RuntimeException('三方仓权威进销存表尚未完成迁移:' . $table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{period_start:string,period_end:string,row_count:int,rows:list<array<string,mixed>>,anchors:list<array{sku_code:string,qty:float}>}
|
||||
*/
|
||||
private function readSource(string $path): array
|
||||
{
|
||||
$archive = new ZipArchive();
|
||||
if ($archive->open($path) !== true) {
|
||||
throw new InvalidArgumentException('无法打开三方仓进销存 XLSX 导出文件。');
|
||||
}
|
||||
|
||||
try {
|
||||
$worksheet = $this->firstWorksheet($archive);
|
||||
$sharedStrings = $this->sharedStrings($path, $archive);
|
||||
} finally {
|
||||
$archive->close();
|
||||
}
|
||||
|
||||
$reader = new XMLReader();
|
||||
if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) {
|
||||
throw new InvalidArgumentException('无法读取三方仓进销存 XLSX 工作表。');
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
$seen = [];
|
||||
$dates = [];
|
||||
$anchors = [];
|
||||
$rows = [];
|
||||
$minimumDate = null;
|
||||
$maximumDate = null;
|
||||
$rowCount = 0;
|
||||
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowNumber = (int) ($reader->getAttribute('r') ?: 0);
|
||||
$values = $this->row($reader->readOuterXml(), $sharedStrings);
|
||||
if ($columns === []) {
|
||||
$columns = $this->headerColumns($values);
|
||||
if ($columns === [] && $rowNumber < 20) {
|
||||
continue;
|
||||
}
|
||||
if ($columns === []) {
|
||||
throw new InvalidArgumentException('前 20 行中未找到三方仓进销存标准表头。');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->emptyRow($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $this->date($values[$columns['日期']] ?? null, $rowNumber);
|
||||
$sku = $this->sku($values[$columns['商品编码']] ?? null, $rowNumber);
|
||||
$key = $date . "\0" . $sku;
|
||||
if (isset($seen[$key])) {
|
||||
throw new InvalidArgumentException(
|
||||
"第 {$rowNumber} 行与第 {$seen[$key]} 行的日期和商品编码重复。",
|
||||
);
|
||||
}
|
||||
$seen[$key] = $rowNumber;
|
||||
|
||||
$opening = $this->number($values[$columns['期初数量']] ?? null, $rowNumber, '期初数量');
|
||||
$closing = $this->number($values[$columns['期末数量']] ?? null, $rowNumber, '期末数量');
|
||||
$movement = 0.0;
|
||||
$movementValues = [];
|
||||
foreach (self::MOVEMENT_HEADERS as $header) {
|
||||
$movementValues[$header] = $this->number($values[$columns[$header]] ?? null, $rowNumber, $header);
|
||||
$movement += $movementValues[$header];
|
||||
}
|
||||
if (abs(($opening + $movement) - $closing) > 0.0001) {
|
||||
throw new InvalidArgumentException(
|
||||
"第 {$rowNumber} 行期初数量加进出数量不等于期末数量。",
|
||||
);
|
||||
}
|
||||
|
||||
$dates[$date] = true;
|
||||
$minimumDate = $minimumDate === null || $date < $minimumDate ? $date : $minimumDate;
|
||||
$maximumDate = $maximumDate === null || $date > $maximumDate ? $date : $maximumDate;
|
||||
$rows[] = [
|
||||
'report_date' => $date,
|
||||
'sku_code' => $sku,
|
||||
'opening_qty' => $opening,
|
||||
'closing_qty' => $closing,
|
||||
'processing_in' => $movementValues['加工进仓'],
|
||||
'processing_out' => $movementValues['加工出仓'],
|
||||
'purchase_in' => $movementValues['采购进仓'],
|
||||
'purchase_return' => $movementValues['采购退货'],
|
||||
'other_in' => $movementValues['其它进仓'],
|
||||
'other_return' => $movementValues['其它退货'],
|
||||
'sales_out' => $movementValues['销售出仓'],
|
||||
'sales_return' => $movementValues['销售退货'],
|
||||
'stocktake' => $movementValues['盘点'],
|
||||
'transfer_out' => $movementValues['调拨出'],
|
||||
'transfer_in' => $movementValues['调拨入'],
|
||||
'opening_aux' => $movementValues['期初'],
|
||||
'other_out' => $movementValues['其它出库'],
|
||||
];
|
||||
if (! isset($anchors[$sku]) || $date >= $anchors[$sku]['date']) {
|
||||
$anchors[$sku] = ['sku_code' => $sku, 'qty' => $closing, 'date' => $date];
|
||||
}
|
||||
$rowCount++;
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
if ($columns === [] || $rowCount === 0 || $minimumDate === null || $maximumDate === null) {
|
||||
throw new InvalidArgumentException('三方仓进销存导出文件中没有可用明细。');
|
||||
}
|
||||
|
||||
$start = Carbon::parse($minimumDate);
|
||||
$end = Carbon::parse($maximumDate);
|
||||
if (! $start->isSameMonth($end)
|
||||
|| $start->day !== 1
|
||||
|| ! $end->isLastOfMonth()) {
|
||||
throw new InvalidArgumentException('导出文件必须完整覆盖同一个自然月。');
|
||||
}
|
||||
for ($date = $start->copy(); $date->lte($end); $date->addDay()) {
|
||||
if (! isset($dates[$date->toDateString()])) {
|
||||
throw new InvalidArgumentException('导出文件缺少日期:' . $date->toDateString());
|
||||
}
|
||||
}
|
||||
if ($anchors === []) {
|
||||
throw new InvalidArgumentException('导出文件月末没有可用的商品期末数量。');
|
||||
}
|
||||
|
||||
ksort($anchors, SORT_STRING);
|
||||
|
||||
$anchors = array_map(
|
||||
static fn (array $anchor): array => [
|
||||
'sku_code' => $anchor['sku_code'],
|
||||
'qty' => $anchor['qty'],
|
||||
],
|
||||
$anchors,
|
||||
);
|
||||
|
||||
return [
|
||||
'period_start' => $minimumDate,
|
||||
'period_end' => $maximumDate,
|
||||
'row_count' => $rowCount,
|
||||
'rows' => $rows,
|
||||
'anchors' => array_values($anchors),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
private function row(string $xml, array $sharedStrings): array
|
||||
{
|
||||
$row = simplexml_load_string($xml);
|
||||
if (! $row instanceof SimpleXMLElement) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) {
|
||||
$reference = (string) ($cell['r'] ?? '');
|
||||
if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$column = Coordinate::columnIndexFromString(strtoupper($matches[1]));
|
||||
$type = (string) ($cell['t'] ?? '');
|
||||
if ($type === 'inlineStr') {
|
||||
$nodes = $cell->xpath('.//*[local-name()="t"]') ?: [];
|
||||
$value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes));
|
||||
} else {
|
||||
$nodes = $cell->xpath('./*[local-name()="v"]') ?: [];
|
||||
$raw = isset($nodes[0]) ? (string) $nodes[0] : '';
|
||||
$value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw;
|
||||
}
|
||||
$values[$column] = $value;
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/** @param array<int,string> $values @return array<string,int> */
|
||||
private function headerColumns(array $values): array
|
||||
{
|
||||
$lookup = [];
|
||||
foreach ($values as $column => $value) {
|
||||
$heading = trim((string) $value);
|
||||
if ($heading !== '') {
|
||||
$lookup[$heading] = $column;
|
||||
}
|
||||
}
|
||||
foreach (self::REQUIRED_HEADERS as $header) {
|
||||
if (! isset($lookup[$header])) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return array_intersect_key($lookup, array_flip(self::REQUIRED_HEADERS));
|
||||
}
|
||||
|
||||
/** @param array<int,string> $values */
|
||||
private function emptyRow(array $values): bool
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (trim((string) $value) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function date(mixed $value, int $rowNumber): string
|
||||
{
|
||||
try {
|
||||
$date = is_numeric($value)
|
||||
? Carbon::instance(ExcelDate::excelToDateTimeObject((float) $value))
|
||||
: Carbon::parse(str_replace('/', '-', trim((string) $value)));
|
||||
} catch (Throwable) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“日期”不是有效日期。");
|
||||
}
|
||||
|
||||
return $date->toDateString();
|
||||
}
|
||||
|
||||
private function sku(mixed $value, int $rowNumber): string
|
||||
{
|
||||
$sku = ProductCatalogAuthoritativeExportImporter::normalizeSkuId((string) $value);
|
||||
if ($sku === '') {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“商品编码”不能为空。");
|
||||
}
|
||||
|
||||
return $sku;
|
||||
}
|
||||
|
||||
private function number(mixed $value, int $rowNumber, string $label): float
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return 0.0;
|
||||
}
|
||||
$normalized = str_replace([',', ','], '', trim((string) $value));
|
||||
if (! is_numeric($normalized)) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效数字。");
|
||||
}
|
||||
|
||||
return round((float) $normalized, 4);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function sharedStrings(string $path, ZipArchive $archive): array
|
||||
{
|
||||
if ($archive->locateName('xl/sharedStrings.xml') === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$reader = new XMLReader();
|
||||
if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) {
|
||||
throw new InvalidArgumentException('无法读取 XLSX 共享文本。');
|
||||
}
|
||||
|
||||
$strings = [];
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') {
|
||||
continue;
|
||||
}
|
||||
$item = simplexml_load_string($reader->readOuterXml());
|
||||
$nodes = $item instanceof SimpleXMLElement
|
||||
? ($item->xpath('.//*[local-name()="t"]') ?: [])
|
||||
: [];
|
||||
$strings[] = implode('', array_map(
|
||||
static fn (SimpleXMLElement $node): string => (string) $node,
|
||||
$nodes,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
return $strings;
|
||||
}
|
||||
|
||||
private function firstWorksheet(ZipArchive $archive): string
|
||||
{
|
||||
$worksheets = [];
|
||||
for ($index = 0; $index < $archive->numFiles; $index++) {
|
||||
$name = $archive->getNameIndex($index);
|
||||
if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) {
|
||||
$worksheets[] = $name;
|
||||
}
|
||||
}
|
||||
sort($worksheets, SORT_NATURAL);
|
||||
if ($worksheets === []) {
|
||||
throw new InvalidArgumentException('XLSX 文件中没有工作表。');
|
||||
}
|
||||
|
||||
return $worksheets[0];
|
||||
}
|
||||
|
||||
private function zipUri(string $path, string $entry): string
|
||||
{
|
||||
return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,520 @@
|
||||
@php
|
||||
$rows = $thirdPartyWarehouseRows ?? null;
|
||||
$rawFilters = is_array($thirdPartyWarehouseFilters ?? null) ? $thirdPartyWarehouseFilters : [];
|
||||
$dimension = in_array(($rawFilters['dimension'] ?? 'month'), ['month', 'day'], true)
|
||||
? $rawFilters['dimension']
|
||||
: 'month';
|
||||
$period = trim((string) ($rawFilters['period'] ?? (
|
||||
$dimension === 'day' ? ($rawFilters['date'] ?? '') : ($rawFilters['month'] ?? '')
|
||||
)));
|
||||
$filters = [
|
||||
'dimension' => $dimension,
|
||||
'period' => $period,
|
||||
'sku_code' => trim((string) ($rawFilters['sku_code'] ?? '')),
|
||||
'product_name' => trim((string) ($rawFilters['product_name'] ?? '')),
|
||||
'supplier' => trim((string) ($rawFilters['supplier'] ?? '')),
|
||||
'category' => trim((string) ($rawFilters['category'] ?? '')),
|
||||
];
|
||||
$summary = $thirdPartyWarehouseSummary ?? [];
|
||||
$sync = array_replace([
|
||||
'code' => 'unavailable',
|
||||
'last_succeeded_at' => null,
|
||||
'lag_minutes' => null,
|
||||
'message' => null,
|
||||
], $thirdPartyWarehouseSync ?? []);
|
||||
$error = trim((string) ($thirdPartyWarehouseError ?? ''));
|
||||
$pending = (bool) ($thirdPartyWarehousePending ?? false);
|
||||
$exportUrl = trim((string) ($thirdPartyWarehouseExportUrl ?? ''));
|
||||
|
||||
$columns = [
|
||||
['key' => 'owner', 'label' => '货主'],
|
||||
['key' => 'date', 'label' => '日期'],
|
||||
['key' => 'sku_code', 'label' => '商品编码', 'type' => 'code'],
|
||||
['key' => 'image_url', 'label' => '图片', 'type' => 'image'],
|
||||
['key' => 'product_name', 'label' => '商品名称', 'type' => 'wide'],
|
||||
['key' => 'short_name', 'label' => '商品简称'],
|
||||
['key' => 'supplier', 'label' => '供应商', 'type' => 'wide'],
|
||||
['key' => 'category', 'label' => '分类'],
|
||||
['key' => 'virtual_category', 'label' => '虚拟分类'],
|
||||
['key' => 'supplier_style_no', 'label' => '供应商款号', 'type' => 'code'],
|
||||
['key' => 'color_spec', 'label' => '颜色规格'],
|
||||
['key' => 'brand', 'label' => '品牌'],
|
||||
['key' => 'unit', 'label' => '单位'],
|
||||
['key' => 'opening_qty', 'label' => '期初数量', 'type' => 'number'],
|
||||
['key' => 'closing_qty', 'label' => '期末数量', 'type' => 'number'],
|
||||
['key' => 'processing_in', 'label' => '加工进仓', 'type' => 'number'],
|
||||
['key' => 'processing_out', 'label' => '加工出仓', 'type' => 'number'],
|
||||
['key' => 'purchase_in', 'label' => '采购进仓', 'type' => 'number'],
|
||||
['key' => 'purchase_return', 'label' => '采购退货', 'type' => 'number'],
|
||||
['key' => 'other_in', 'label' => '其它进仓', 'type' => 'number'],
|
||||
['key' => 'other_return', 'label' => '其它退货', 'type' => 'number'],
|
||||
['key' => 'sales_out', 'label' => '销售出仓', 'type' => 'number'],
|
||||
['key' => 'sales_return', 'label' => '销售退货', 'type' => 'number'],
|
||||
['key' => 'stocktake', 'label' => '盘点', 'type' => 'number'],
|
||||
['key' => 'transfer_out', 'label' => '调拨出', 'type' => 'number'],
|
||||
['key' => 'transfer_in', 'label' => '调拨入', 'type' => 'number'],
|
||||
['key' => 'opening_aux', 'label' => '期初', 'type' => 'number'],
|
||||
['key' => 'other_out', 'label' => '其它出库', 'type' => 'number'],
|
||||
];
|
||||
|
||||
$statusLabels = [
|
||||
'healthy' => '同步正常',
|
||||
'fresh' => '同步正常',
|
||||
'syncing' => '同步中,显示最近数据',
|
||||
'reconciling' => '全量校准中,显示最近数据',
|
||||
'stale' => '同步延迟,显示最近数据',
|
||||
'delayed' => '同步延迟,显示最近数据',
|
||||
'failed' => '同步失败,显示最近数据',
|
||||
'initializing' => '首次同步中',
|
||||
'dependency_pending' => '商品资料同步中',
|
||||
'pending' => '等待首次同步',
|
||||
'initial_failed' => '首次同步失败',
|
||||
'unavailable' => '镜像不可用',
|
||||
];
|
||||
$statusCode = (string) ($sync['code'] ?? 'unavailable');
|
||||
$statusLabel = $statusLabels[$statusCode] ?? '同步状态未知';
|
||||
$statusTone = match ($statusCode) {
|
||||
'failed', 'initial_failed', 'unavailable' => 'danger',
|
||||
'stale', 'delayed', 'initializing', 'dependency_pending', 'pending' => 'warning',
|
||||
'syncing', 'reconciling' => 'info',
|
||||
default => 'success',
|
||||
};
|
||||
$rowTotal = is_object($rows) && method_exists($rows, 'total')
|
||||
? (int) $rows->total()
|
||||
: (is_countable($rows) ? count($rows) : 0);
|
||||
$rowCount = is_object($rows) && method_exists($rows, 'count')
|
||||
? (int) $rows->count()
|
||||
: (is_countable($rows) ? count($rows) : 0);
|
||||
$summaryReady = ! $pending && collect(['count', 'opening_qty', 'closing_qty'])
|
||||
->every(static fn (string $key): bool => array_key_exists($key, $summary));
|
||||
$showingLastVersion = $rowTotal > 0 && ($error !== '' || in_array($statusCode, [
|
||||
'syncing', 'reconciling', 'stale', 'delayed', 'failed',
|
||||
], true));
|
||||
$canExport = $exportUrl !== '' && $rowTotal > 0 && ! $pending;
|
||||
$activeFilters = array_filter(
|
||||
$filters,
|
||||
static fn (mixed $value): bool => $value !== null && $value !== ''
|
||||
);
|
||||
if (is_object($rows) && method_exists($rows, 'appends')) {
|
||||
$rows->appends($activeFilters);
|
||||
}
|
||||
$fallbackExportUrl = $exportUrl;
|
||||
|
||||
if ($exportUrl !== '' && $activeFilters !== []) {
|
||||
$fallbackExportUrl .= (str_contains($exportUrl, '?') ? '&' : '?') . http_build_query($activeFilters);
|
||||
}
|
||||
|
||||
$text = static function (mixed $value): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '是' : '否';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '-';
|
||||
};
|
||||
$number = static function (mixed $value) use ($text): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return $text($value);
|
||||
}
|
||||
|
||||
return rtrim(rtrim(number_format((float) $value, 4, '.', ','), '0'), '.');
|
||||
};
|
||||
$money = static function (mixed $value) use ($text): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return is_numeric($value) ? number_format((float) $value, 2, '.', ',') : $text($value);
|
||||
};
|
||||
$imageUrl = static function (mixed $value): ?string {
|
||||
if (is_array($value)) {
|
||||
$value = $value['url'] ?? $value['src'] ?? reset($value);
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = trim($value);
|
||||
|
||||
if (str_starts_with($candidate, '[') || str_starts_with($candidate, '{')) {
|
||||
$decoded = json_decode($candidate, true);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
$candidate = trim((string) ($decoded['url'] ?? $decoded['src'] ?? reset($decoded)));
|
||||
}
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME));
|
||||
|
||||
return filter_var($candidate, FILTER_VALIDATE_URL) && in_array($scheme, ['http', 'https'], true)
|
||||
? $candidate
|
||||
: null;
|
||||
};
|
||||
$lagMinutes = is_numeric($sync['lag_minutes'] ?? null) ? max(0, (int) $sync['lag_minutes']) : null;
|
||||
$lagLabel = $lagMinutes === null
|
||||
? null
|
||||
: ($lagMinutes < 60 ? $lagMinutes . ' 分钟' : floor($lagMinutes / 60) . ' 小时 ' . ($lagMinutes % 60) . ' 分钟');
|
||||
@endphp
|
||||
|
||||
<div class="third-party-warehouse-page" data-finance-table-surface>
|
||||
<style>
|
||||
.third-party-warehouse-page {
|
||||
--warehouse-border: var(--vip-border, #D8D2C7);
|
||||
--warehouse-ink: var(--vip-content-ink, #171717);
|
||||
--warehouse-muted: var(--vip-content-muted, #595959);
|
||||
--warehouse-accent: var(--vip-content-accent-dark, #856632);
|
||||
--warehouse-soft: var(--vip-content-accent-soft, #F7F0DE);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--warehouse-ink);
|
||||
font-size: .8125rem;
|
||||
}
|
||||
.third-party-warehouse-status { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem 1rem; min-height:2.625rem; padding:.5rem .75rem; border:1px solid var(--warehouse-border); border-radius:.5rem; background:var(--warehouse-soft); }
|
||||
.third-party-warehouse-status__primary,.third-party-warehouse-status__meta { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
|
||||
.third-party-warehouse-status__primary { font-weight:600; }
|
||||
.third-party-warehouse-status__meta { color:var(--warehouse-muted); font-size:.75rem; }
|
||||
.third-party-warehouse-status__dot { width:.5rem; height:.5rem; flex:0 0 auto; border-radius:50%; background:var(--vip-success,#53793E); }
|
||||
.third-party-warehouse-status__dot.is-warning { background:var(--vip-warning,#935F07); }
|
||||
.third-party-warehouse-status__dot.is-danger { background:var(--vip-danger,#B80000); }
|
||||
.third-party-warehouse-status__dot.is-info { background:var(--vip-info,#006395); }
|
||||
.third-party-warehouse-alert { display:flex; align-items:flex-start; gap:.5rem; padding:.625rem .75rem; border:1px solid rgba(184,0,0,.35); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); }
|
||||
.third-party-warehouse-notice { display:flex; align-items:flex-start; gap:.5rem; padding:.625rem .75rem; border:1px solid rgba(147,95,7,.35); border-radius:.5rem; background:#fffaf0; color:var(--vip-warning,#935F07); }
|
||||
.third-party-warehouse-alert .material-icons-outlined,.third-party-warehouse-notice .material-icons-outlined { margin-top:.05rem; font-size:1.1rem; }
|
||||
.third-party-warehouse-filters { display:flex; flex-wrap:wrap; align-items:flex-end; gap:.65rem; padding:.75rem; border:1px solid var(--warehouse-border); border-radius:.5rem; background:var(--vip-surface,#fff); }
|
||||
.third-party-warehouse-field { display:flex; min-width:8.75rem; flex:1 1 10rem; flex-direction:column; gap:.3rem; }
|
||||
.third-party-warehouse-field:first-of-type { flex:0 1 9.5rem; }
|
||||
.third-party-warehouse-field label { color:var(--warehouse-muted); font-size:.75rem; font-weight:600; }
|
||||
.third-party-warehouse-field input { width:100%; height:2.125rem; padding:0 .625rem; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; color:var(--warehouse-ink); font:inherit; }
|
||||
.third-party-warehouse-field input:focus { border-color:var(--warehouse-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.third-party-warehouse-dimension { display:flex; min-width:10.75rem; flex:0 0 auto; flex-direction:column; gap:.3rem; margin:0; padding:0; border:0; }
|
||||
.third-party-warehouse-dimension__label { color:var(--warehouse-muted); font-size:.75rem; font-weight:600; }
|
||||
.third-party-warehouse-segmented { display:inline-grid; height:2.125rem; grid-template-columns:repeat(2,1fr); overflow:hidden; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; }
|
||||
.third-party-warehouse-segmented label { position:relative; display:flex; min-width:5rem; align-items:center; justify-content:center; padding:0 .65rem; color:var(--warehouse-muted); cursor:pointer; font-size:.75rem; font-weight:600; }
|
||||
.third-party-warehouse-segmented label + label { border-left:1px solid var(--warehouse-border); }
|
||||
.third-party-warehouse-segmented input { position:absolute; width:1px; height:1px; overflow:hidden; opacity:0; pointer-events:none; }
|
||||
.third-party-warehouse-segmented label:has(input:checked) { background:var(--warehouse-accent); color:#fff; }
|
||||
.third-party-warehouse-segmented label:has(input:focus-visible) { outline:2px solid var(--warehouse-accent); outline-offset:-3px; }
|
||||
.third-party-warehouse-actions { display:flex; flex:1 1 29rem; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:.4rem; }
|
||||
.third-party-warehouse-button,.third-party-warehouse-actions [data-finance-server-export] { display:inline-flex; height:2.125rem; align-items:center; justify-content:center; gap:.3rem; padding:0 .7rem; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; color:var(--warehouse-ink); cursor:pointer; font:inherit; font-weight:600; line-height:1; text-decoration:none; white-space:nowrap; }
|
||||
.third-party-warehouse-button:hover,.third-party-warehouse-actions [data-finance-server-export]:hover { border-color:var(--warehouse-accent); background:var(--warehouse-soft); }
|
||||
.third-party-warehouse-button:focus-visible,.third-party-warehouse-actions [data-finance-server-export]:focus-visible { border-color:var(--warehouse-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.third-party-warehouse-button.is-primary { border-color:var(--warehouse-accent); background:var(--warehouse-accent); color:#fff; }
|
||||
.third-party-warehouse-button.is-primary:hover { background:#6A5129; }
|
||||
.third-party-warehouse-button.is-disabled,.third-party-warehouse-actions [data-finance-server-export][disabled] { opacity:.5; cursor:not-allowed; pointer-events:none; }
|
||||
.third-party-warehouse-button .material-icons-outlined,.third-party-warehouse-actions .material-icons-outlined { font-size:1rem; }
|
||||
.third-party-warehouse-actions [data-finance-server-export] { display:none; }
|
||||
.third-party-warehouse-page[data-finance-enhanced] .third-party-warehouse-actions [data-finance-server-export] { display:inline-flex; }
|
||||
.third-party-warehouse-page .finance-table-field-picker__panel { right:0; left:auto; }
|
||||
.third-party-warehouse-summary { display:grid; grid-template-columns:repeat(5,minmax(8rem,1fr)); overflow:hidden; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; }
|
||||
.third-party-warehouse-summary__item { min-width:0; padding:.625rem .75rem; border-right:1px solid var(--warehouse-border); }
|
||||
.third-party-warehouse-summary__item:last-child { border-right:0; }
|
||||
.third-party-warehouse-summary__label { display:block; color:var(--warehouse-muted); font-size:.7rem; }
|
||||
.third-party-warehouse-summary__value { display:block; margin-top:.15rem; overflow:hidden; color:#4B3A24; font-size:.9375rem; font-variant-numeric:tabular-nums; font-weight:700; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.third-party-warehouse-table-panel { overflow:hidden; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; }
|
||||
.third-party-warehouse-table-scroll { width:100%; height:min(56vh,42rem); min-height:16rem; overflow:auto; overscroll-behavior:contain; scrollbar-color:var(--vip-content-accent,#B9954F) #EFEDE8; scrollbar-gutter:stable both-edges; scrollbar-width:thin; }
|
||||
.third-party-warehouse-table-scroll::-webkit-scrollbar { width:.5rem; height:.5rem; }
|
||||
.third-party-warehouse-table-scroll::-webkit-scrollbar-thumb { border-radius:.25rem; background:var(--vip-content-accent,#B9954F); }
|
||||
.third-party-warehouse-table-scroll::-webkit-scrollbar-track { background:#EFEDE8; }
|
||||
.third-party-warehouse-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.78125rem; }
|
||||
.third-party-warehouse-table th,.third-party-warehouse-table td { min-width:7.25rem; max-width:15rem; padding:.5rem .625rem; border-right:1px solid var(--warehouse-border); border-bottom:1px solid #EEEAE3; background:#fff; line-height:1.45; text-align:left; vertical-align:middle; white-space:nowrap; }
|
||||
.third-party-warehouse-table th { position:sticky; top:0; z-index:4; border-bottom-color:var(--warehouse-border); background:var(--warehouse-soft); color:#4F4A43; font-size:.75rem; font-weight:600; }
|
||||
.third-party-warehouse-table tbody tr:hover td { background:#FCFAF5; }
|
||||
.third-party-warehouse-table .is-number { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.third-party-warehouse-table .is-wide { min-width:14rem; max-width:20rem; overflow:hidden; text-overflow:ellipsis; }
|
||||
.third-party-warehouse-table .is-code { min-width:10rem; font-variant-numeric:tabular-nums; }
|
||||
.third-party-warehouse-table .is-image { min-width:4.75rem; width:4.75rem; text-align:center; }
|
||||
.third-party-warehouse-thumb { display:block; width:2.5rem; height:2.5rem; margin:auto; border:1px solid var(--warehouse-border); border-radius:.25rem; background:#F8F6F1; object-fit:cover; }
|
||||
.third-party-warehouse-empty { height:15rem; color:var(--warehouse-muted); text-align:center !important; white-space:normal !important; }
|
||||
.third-party-warehouse-pagination { display:flex; min-height:2.75rem; align-items:center; justify-content:space-between; gap:.75rem; padding:.5rem .75rem; border-top:1px solid var(--warehouse-border); color:var(--warehouse-muted); font-size:.75rem; }
|
||||
.third-party-warehouse-pagination__controls { display:flex; align-items:center; gap:.4rem; }
|
||||
@media (max-width: 960px) {
|
||||
.third-party-warehouse-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.third-party-warehouse-summary__item { border-bottom:1px solid var(--warehouse-border); }
|
||||
.third-party-warehouse-actions { justify-content:flex-start; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.third-party-warehouse-field { flex-basis:100%; }
|
||||
.third-party-warehouse-actions { flex-basis:100%; }
|
||||
.third-party-warehouse-summary { grid-template-columns:1fr; }
|
||||
.third-party-warehouse-summary__item { border-right:0; }
|
||||
.third-party-warehouse-table-scroll { height:52vh; min-height:14rem; }
|
||||
.third-party-warehouse-pagination { align-items:flex-start; flex-direction:column; }
|
||||
.third-party-warehouse-page .finance-table-field-picker__panel { right:auto; left:0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="third-party-warehouse-status" role="status" aria-live="polite">
|
||||
<div class="third-party-warehouse-status__primary">
|
||||
<span @class(['third-party-warehouse-status__dot', 'is-' . $statusTone]) aria-hidden="true"></span>
|
||||
<span>{{ $statusLabel }}</span>
|
||||
@if ($showingLastVersion)
|
||||
<span>· 当前展示最近一次成功同步的数据</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="third-party-warehouse-status__meta">
|
||||
<span>三方-南昌樵舍仓 · 查询维度:商品 + 按天</span>
|
||||
<span>每日 00:00 自动同步</span>
|
||||
@if (! empty($sync['last_succeeded_at']))
|
||||
<span>最近成功同步 {{ $sync['last_succeeded_at'] }}</span>
|
||||
@endif
|
||||
@if ($lagLabel !== null)
|
||||
<span>延迟 {{ $lagLabel }}</span>
|
||||
@endif
|
||||
@if ($error === '' && ! empty($sync['message']))
|
||||
<span>{{ $sync['message'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($error !== '')
|
||||
<div class="third-party-warehouse-alert" role="alert">
|
||||
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
|
||||
<span>
|
||||
@if ($showingLastVersion)
|
||||
本次同步或读取异常,已保留上一版数据:{{ $error }}
|
||||
@elseif ($pending)
|
||||
首次同步未完成:{{ $error }}
|
||||
@else
|
||||
数据暂时无法读取:{{ $error }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($pending)
|
||||
<div class="third-party-warehouse-notice" role="status">
|
||||
<span class="material-icons-outlined" aria-hidden="true">schedule</span>
|
||||
<span>首次同步尚未完成,当前没有可展示的正式镜像数据;同步成功后页面会自动使用镜像数据。</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="GET" class="third-party-warehouse-filters" aria-label="三方仓进销存筛选" data-finance-controls>
|
||||
<fieldset class="third-party-warehouse-dimension">
|
||||
<legend class="third-party-warehouse-dimension__label">查询维度</legend>
|
||||
<div class="third-party-warehouse-segmented" data-warehouse-dimension>
|
||||
<label>
|
||||
<input type="radio" name="dimension" value="month" @checked($dimension === 'month')>
|
||||
<span>按月</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="dimension" value="day" @checked($dimension === 'day')>
|
||||
<span>按天</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="third-party-warehouse-field">
|
||||
<label for="warehouse-period" data-warehouse-period-label>{{ $dimension === 'day' ? '日期' : '月份' }}</label>
|
||||
<input
|
||||
id="warehouse-period"
|
||||
type="{{ $dimension === 'day' ? 'date' : 'month' }}"
|
||||
name="period"
|
||||
value="{{ $filters['period'] }}"
|
||||
data-warehouse-period
|
||||
>
|
||||
</div>
|
||||
<div class="third-party-warehouse-field">
|
||||
<label for="warehouse-sku-code">商品编码</label>
|
||||
<input id="warehouse-sku-code" type="search" name="sku_code" value="{{ $filters['sku_code'] }}" placeholder="输入商品编码">
|
||||
</div>
|
||||
<div class="third-party-warehouse-field">
|
||||
<label for="warehouse-product-name">商品名称</label>
|
||||
<input id="warehouse-product-name" type="search" name="product_name" value="{{ $filters['product_name'] }}" placeholder="支持关键词筛选">
|
||||
</div>
|
||||
<div class="third-party-warehouse-field">
|
||||
<label for="warehouse-supplier">供应商</label>
|
||||
<input id="warehouse-supplier" type="search" name="supplier" value="{{ $filters['supplier'] }}" placeholder="支持关键词筛选">
|
||||
</div>
|
||||
<div class="third-party-warehouse-field">
|
||||
<label for="warehouse-category">分类</label>
|
||||
<input id="warehouse-category" type="search" name="category" value="{{ $filters['category'] }}" placeholder="支持关键词筛选">
|
||||
</div>
|
||||
<div class="third-party-warehouse-actions">
|
||||
<button class="third-party-warehouse-button is-primary" type="submit">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span><span>筛选</span>
|
||||
</button>
|
||||
<a class="third-party-warehouse-button" href="{{ url()->current() }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span><span>重置</span>
|
||||
</a>
|
||||
<a
|
||||
@class(['third-party-warehouse-button', 'finance-table-fallback-export', 'is-disabled' => ! $canExport])
|
||||
data-finance-fallback-export
|
||||
href="{{ $canExport ? $fallbackExportUrl : '#' }}"
|
||||
@if (! $canExport) aria-disabled="true" @endif
|
||||
>
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出筛选结果</span>
|
||||
</a>
|
||||
<button type="button" data-finance-server-export="visible" @disabled(! $canExport)>
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出显示列</span>
|
||||
</button>
|
||||
<button type="button" data-finance-server-export="all" @disabled(! $canExport)>
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出全部列</span>
|
||||
</button>
|
||||
<div class="finance-table-field-picker" data-finance-field-picker>
|
||||
<button type="button" class="third-party-warehouse-button" aria-expanded="false">
|
||||
<span class="material-icons-outlined" aria-hidden="true">view_column</span><span>列设置</span>
|
||||
</button>
|
||||
<div class="finance-table-field-picker__panel" role="group" aria-label="选择展示字段" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="third-party-warehouse-summary" aria-label="当前筛选汇总">
|
||||
@foreach ([
|
||||
['key' => 'count', 'label' => '明细记录数', 'type' => 'count'],
|
||||
['key' => 'opening_qty', 'label' => '期初数量合计', 'type' => 'number'],
|
||||
['key' => 'closing_qty', 'label' => '期末数量合计', 'type' => 'number'],
|
||||
] as $metric)
|
||||
@php
|
||||
$metricValue = $summaryReady ? $summary[$metric['key']] : null;
|
||||
$metricDisplay = match ($metric['type']) {
|
||||
'count' => $metricValue === null ? '—' : number_format((int) $metricValue),
|
||||
'money' => $metricValue === null ? '—' : '¥ ' . $money($metricValue),
|
||||
default => $metricValue === null ? '—' : $number($metricValue),
|
||||
};
|
||||
@endphp
|
||||
<div class="third-party-warehouse-summary__item">
|
||||
<span class="third-party-warehouse-summary__label">{{ $metric['label'] }}</span>
|
||||
<span class="third-party-warehouse-summary__value">{{ $metricDisplay }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="third-party-warehouse-table-panel">
|
||||
<div class="third-party-warehouse-table-scroll finance-table-scroll-owner">
|
||||
<table
|
||||
class="third-party-warehouse-table"
|
||||
aria-label="三方仓进销存"
|
||||
data-finance-table
|
||||
@if ($exportUrl !== '') data-finance-export-base="{{ $exportUrl }}" @endif
|
||||
data-finance-export-query="{{ json_encode($activeFilters, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}"
|
||||
data-finance-export-disabled="{{ $canExport ? 'false' : 'true' }}"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
@foreach ($columns as $column)
|
||||
<th
|
||||
scope="col"
|
||||
data-finance-column-key="{{ $column['key'] }}"
|
||||
data-export-field="{{ $column['key'] }}"
|
||||
@class([
|
||||
'is-number' => in_array($column['type'] ?? null, ['number', 'money'], true),
|
||||
'is-image' => ($column['type'] ?? null) === 'image',
|
||||
'is-wide' => ($column['type'] ?? null) === 'wide',
|
||||
'is-code' => ($column['type'] ?? null) === 'code',
|
||||
])
|
||||
>{{ $column['label'] }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($rows ?? [] as $row)
|
||||
<tr>
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$value = data_get($row, $column['key']);
|
||||
$type = $column['type'] ?? 'text';
|
||||
$displayValue = match ($type) {
|
||||
'number' => $number($value),
|
||||
'money' => $money($value),
|
||||
default => $text($value),
|
||||
};
|
||||
@endphp
|
||||
<td
|
||||
@class([
|
||||
'is-number' => in_array($type, ['number', 'money'], true),
|
||||
'is-image' => $type === 'image',
|
||||
'is-wide' => $type === 'wide',
|
||||
'is-code' => $type === 'code',
|
||||
])
|
||||
@if ($type === 'wide' && $displayValue !== '-') title="{{ $displayValue }}" @endif
|
||||
@if ($type === 'image') data-export-value="{{ $text($value) }}" @endif
|
||||
>
|
||||
@if ($type === 'image')
|
||||
@php($safeImageUrl = $imageUrl($value))
|
||||
@if ($safeImageUrl !== null)
|
||||
<a href="{{ $safeImageUrl }}" target="_blank" rel="noopener noreferrer" aria-label="查看{{ $text(data_get($row, 'product_name')) }}图片">
|
||||
<img class="third-party-warehouse-thumb" src="{{ $safeImageUrl }}" alt="" width="40" height="40" loading="lazy" decoding="async" referrerpolicy="no-referrer">
|
||||
</a>
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
@else
|
||||
{{ $displayValue }}
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
<tr class="is-empty">
|
||||
<td class="third-party-warehouse-empty" colspan="{{ count($columns) }}">
|
||||
@if ($pending)
|
||||
首次同步尚未完成,当前不会显示空白数据冒充正式结果。
|
||||
@elseif ($error !== '')
|
||||
镜像数据读取失败,当前没有可展示的数据。
|
||||
@elseif (in_array($statusCode, ['initial_failed', 'unavailable'], true))
|
||||
镜像尚未准备好,请等待同步恢复后再查看。
|
||||
@else
|
||||
当前期间及筛选条件下没有数据,请调整条件或重置筛选。
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="third-party-warehouse-pagination">
|
||||
<span>共 {{ number_format($rowTotal) }} 条记录,本页 {{ number_format($rowCount) }} 条</span>
|
||||
@if (is_object($rows) && method_exists($rows, 'currentPage'))
|
||||
<div class="third-party-warehouse-pagination__controls" aria-label="三方仓进销存分页">
|
||||
<a
|
||||
@class(['third-party-warehouse-button', 'is-disabled' => $rows->onFirstPage()])
|
||||
href="{{ $rows->previousPageUrl() ?: '#' }}"
|
||||
aria-label="上一页"
|
||||
@if ($rows->onFirstPage()) aria-disabled="true" @endif
|
||||
><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
|
||||
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页</span>
|
||||
<a
|
||||
@class(['third-party-warehouse-button', 'is-disabled' => ! $rows->hasMorePages()])
|
||||
href="{{ $rows->nextPageUrl() ?: '#' }}"
|
||||
aria-label="下一页"
|
||||
@if (! $rows->hasMorePages()) aria-disabled="true" @endif
|
||||
><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const page = document.currentScript.closest('.third-party-warehouse-page');
|
||||
const period = page?.querySelector('[data-warehouse-period]');
|
||||
const periodLabel = page?.querySelector('[data-warehouse-period-label]');
|
||||
|
||||
if (!period || !periodLabel) return;
|
||||
|
||||
page.querySelectorAll('[data-warehouse-dimension] input[name="dimension"]').forEach((control) => {
|
||||
control.addEventListener('change', () => {
|
||||
const nextType = control.value === 'day' ? 'date' : 'month';
|
||||
if (period.type !== nextType) period.value = '';
|
||||
period.type = nextType;
|
||||
periodLabel.textContent = nextType === 'date' ? '日期' : '月份';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@ -142,6 +142,10 @@ Route::get('workspace/courier-payables/courier-information/export', 'Business\Pa
|
||||
->middleware('business.page:courier-payables,courier-information')
|
||||
->name('business.courier-payables.courier-information.export');
|
||||
|
||||
Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage')
|
||||
->middleware('business.page:courier-payables,third-party-warehouse-storage')
|
||||
->name('business.courier-payables.third-party-warehouse-storage.export');
|
||||
|
||||
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
|
||||
->name('business.pages.export');
|
||||
|
||||
|
||||
240
tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php
Normal file
240
tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php
Normal file
@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class ThirdPartyWarehouseStoragePageTest extends FeatureTestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_page_passes_a_daily_period_and_filters_to_the_mirror(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
setting()->set('default.list_limit', 25);
|
||||
|
||||
$filters = [
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-08-15',
|
||||
'sku_code' => 'BG002950N956V01',
|
||||
'product_name' => '餐椅',
|
||||
'supplier' => '测试供应商',
|
||||
'category' => '家具',
|
||||
];
|
||||
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->with($filters, 1, 25)->andReturn([
|
||||
'data' => [$this->row()],
|
||||
'total' => 1,
|
||||
'summary' => [
|
||||
'count' => 1,
|
||||
'opening_qty' => 0,
|
||||
'closing_qty' => 10,
|
||||
],
|
||||
'state' => ['status' => 'succeeded'],
|
||||
'sync' => [
|
||||
'code' => 'healthy',
|
||||
'last_succeeded_at' => '2026-09-11 03:20:00',
|
||||
'lag_minutes' => 10,
|
||||
],
|
||||
]);
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs();
|
||||
$this->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'third-party-warehouse-storage',
|
||||
]) . '?' . http_build_query($filters))
|
||||
->assertOk()
|
||||
->assertSee('三方仓进销存')
|
||||
->assertSee('查询维度:商品 + 按天')
|
||||
->assertSee('value="day" checked', false)
|
||||
->assertSee('type="date"', false)
|
||||
->assertSee('value="2026-08-15"', false)
|
||||
->assertSee('BG002950N956V01')
|
||||
->assertSee('测试供应商')
|
||||
->assertSee('每日 00:00 自动同步');
|
||||
}
|
||||
|
||||
public function test_invalid_daily_period_falls_back_to_the_previous_complete_day(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-09-11 16:00:00');
|
||||
$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()->with(
|
||||
Mockery::on(static fn (array $filters): bool => $filters['dimension'] === 'day'
|
||||
&& $filters['period'] === '2026-09-10'),
|
||||
1,
|
||||
Mockery::type('int'),
|
||||
)->andReturn($this->emptyResult());
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs();
|
||||
$this->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'third-party-warehouse-storage',
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-02-31',
|
||||
]))->assertOk()
|
||||
->assertSee('value="2026-09-10"', false);
|
||||
}
|
||||
|
||||
public function test_period_before_supported_start_date_is_clamped(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
|
||||
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->twice()->andReturnTrue();
|
||||
$repository->shouldReceive('ready')->twice()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->with(
|
||||
Mockery::on(static fn (array $filters): bool => $filters['dimension'] === 'day'
|
||||
&& $filters['period'] === '2026-08-01'),
|
||||
1,
|
||||
Mockery::type('int'),
|
||||
)->andReturn($this->emptyResult());
|
||||
$repository->shouldReceive('paginate')->once()->with(
|
||||
Mockery::on(static fn (array $filters): bool => $filters['dimension'] === 'month'
|
||||
&& $filters['period'] === '2026-08'),
|
||||
1,
|
||||
Mockery::type('int'),
|
||||
)->andReturn($this->emptyResult());
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs();
|
||||
$route = route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'third-party-warehouse-storage',
|
||||
]);
|
||||
$this->get($route . '?dimension=day&period=2026-07-31')
|
||||
->assertOk()
|
||||
->assertSee('value="2026-08-01"', false);
|
||||
$this->get($route . '?dimension=month&period=2026-07')
|
||||
->assertOk()
|
||||
->assertSee('value="2026-08"', false);
|
||||
}
|
||||
|
||||
public function test_export_keeps_daily_filters_and_only_accepts_declared_fields(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$filters = [
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-08-15',
|
||||
'sku_code' => 'BG002950N956V01',
|
||||
'product_name' => '',
|
||||
'supplier' => '',
|
||||
'category' => '',
|
||||
];
|
||||
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('exportRows')->once()->with($filters)->andReturn((static function (): \Generator {
|
||||
yield self::row();
|
||||
})());
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route(
|
||||
'business.courier-payables.third-party-warehouse-storage.export',
|
||||
$filters + ['fields' => 'sku_code,closing_qty,not_allowed'],
|
||||
));
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringContainsString('text/csv', (string) $response->headers->get('content-type'));
|
||||
$content = $response->streamedContent();
|
||||
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
|
||||
$this->assertStringContainsString('商品编码,期末数量', $content);
|
||||
$this->assertStringContainsString("'BG002950N956V01,10", $content);
|
||||
$this->assertStringNotContainsString('货主', $content);
|
||||
$this->assertStringNotContainsString('not_allowed', $content);
|
||||
}
|
||||
|
||||
public function test_page_explains_that_an_uninitialized_mirror_is_not_empty_business_data(): 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()->andReturnFalse();
|
||||
$repository->shouldReceive('state')->once()->andReturn(['status' => 'pending']);
|
||||
$repository->shouldReceive('syncStatus')->once()->andReturn([
|
||||
'code' => 'pending',
|
||||
'last_succeeded_at' => null,
|
||||
'lag_minutes' => null,
|
||||
]);
|
||||
$repository->shouldNotReceive('paginate');
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs();
|
||||
$this->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'third-party-warehouse-storage',
|
||||
]))->assertOk()
|
||||
->assertSee('首次同步尚未完成')
|
||||
->assertSee('不会显示空白数据冒充正式结果');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function row(): array
|
||||
{
|
||||
return [
|
||||
'owner' => '常州简尚家居有限公司',
|
||||
'date' => '2026-08-15',
|
||||
'sku_code' => 'BG002950N956V01',
|
||||
'image_url' => '',
|
||||
'product_name' => '测试餐椅',
|
||||
'short_name' => '餐椅',
|
||||
'supplier' => '测试供应商',
|
||||
'category' => '家具',
|
||||
'virtual_category' => '',
|
||||
'supplier_style_no' => 'SUP-001',
|
||||
'color_spec' => '原木色',
|
||||
'brand' => '测试品牌',
|
||||
'unit' => '件',
|
||||
'opening_qty' => 0,
|
||||
'closing_qty' => 10,
|
||||
'processing_in' => 0,
|
||||
'processing_out' => 0,
|
||||
'purchase_in' => 34,
|
||||
'purchase_return' => 0,
|
||||
'other_in' => 0,
|
||||
'other_return' => 0,
|
||||
'sales_out' => -24,
|
||||
'sales_return' => 0,
|
||||
'stocktake' => 0,
|
||||
'transfer_out' => 0,
|
||||
'transfer_in' => 0,
|
||||
'opening_aux' => 0,
|
||||
'other_out' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function emptyResult(): array
|
||||
{
|
||||
return [
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'summary' => [
|
||||
'count' => 0,
|
||||
'opening_qty' => 0,
|
||||
'closing_qty' => 0,
|
||||
],
|
||||
'state' => ['status' => 'succeeded'],
|
||||
'sync' => [
|
||||
'code' => 'healthy',
|
||||
'last_succeeded_at' => '2026-09-11 03:20:00',
|
||||
'lag_minutes' => 10,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageAuthoritativeAnchorImporter;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ThirdPartyWarehouseStorageAuthoritativeAnchorImportTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $temporaryFiles = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$this->createTables();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->temporaryFiles as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_imports_authoritative_daily_rows_and_month_end_anchor(): void
|
||||
{
|
||||
$path = $this->validWorkbook();
|
||||
|
||||
$this->artisan('business-mirror:import-third-party-warehouse-anchor', ['path' => $path])
|
||||
->assertSuccessful();
|
||||
|
||||
$state = DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_authoritative_anchor_states')
|
||||
->where('dataset_key', ThirdPartyWarehouseStorageAuthoritativeAnchorImporter::DATASET_KEY)
|
||||
->first();
|
||||
|
||||
$this->assertSame('succeeded', $state->status);
|
||||
$this->assertSame('2026-08-01', $state->source_period_start);
|
||||
$this->assertSame('2026-08-31', $state->source_period_end);
|
||||
$this->assertSame(32, (int) $state->source_row_count);
|
||||
$this->assertSame(2, (int) $state->anchor_sku_count);
|
||||
$this->assertSame('2026-09-01 00:00:00', $state->anchor_at);
|
||||
$this->assertDatabaseHas('third_party_warehouse_authoritative_anchors', [
|
||||
'generation' => $state->active_generation,
|
||||
'sku_code' => 'SKU-A',
|
||||
'qty' => 41,
|
||||
'anchor_at' => '2026-09-01 00:00:00',
|
||||
], 'business_mirror');
|
||||
$this->assertDatabaseHas('third_party_warehouse_authoritative_anchors', [
|
||||
'generation' => $state->active_generation,
|
||||
'sku_code' => 'CODE+PLUS',
|
||||
'qty' => 7,
|
||||
], 'business_mirror');
|
||||
$this->assertSame(
|
||||
32,
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_rows')
|
||||
->where('generation', $state->active_generation)->count(),
|
||||
);
|
||||
$this->assertDatabaseHas('third_party_warehouse_authoritative_rows', [
|
||||
'generation' => $state->active_generation,
|
||||
'report_date' => '2026-08-01',
|
||||
'sku_code' => 'SKU-A',
|
||||
'opening_qty' => 10,
|
||||
'closing_qty' => 11,
|
||||
'purchase_in' => 1,
|
||||
], 'business_mirror');
|
||||
}
|
||||
|
||||
public function test_invalid_replacement_keeps_the_previous_active_generation(): void
|
||||
{
|
||||
$valid = app(ThirdPartyWarehouseStorageAuthoritativeAnchorImporter::class)
|
||||
->import($this->validWorkbook());
|
||||
$invalid = $this->workbook([
|
||||
$this->headers(),
|
||||
$this->row('2026-08-01', 'SKU-A', 10, 9, ['销售出仓' => -2]),
|
||||
]);
|
||||
|
||||
$this->artisan('business-mirror:import-third-party-warehouse-anchor', ['path' => $invalid])
|
||||
->expectsOutputToContain('已保留上一版有效数据')
|
||||
->assertFailed();
|
||||
|
||||
$state = DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_authoritative_anchor_states')
|
||||
->first();
|
||||
$this->assertSame('failed', $state->status);
|
||||
$this->assertSame($valid['generation'], $state->active_generation);
|
||||
$this->assertNull($state->pending_generation);
|
||||
$this->assertSame(
|
||||
2,
|
||||
DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_authoritative_anchors')
|
||||
->where('generation', $valid['generation'])
|
||||
->count(),
|
||||
);
|
||||
$this->assertSame(
|
||||
32,
|
||||
DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_authoritative_rows')
|
||||
->where('generation', $valid['generation'])
|
||||
->count(),
|
||||
);
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('third_party_warehouse_authoritative_anchor_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status');
|
||||
$table->string('active_generation')->nullable();
|
||||
$table->string('pending_generation')->nullable();
|
||||
$table->dateTime('anchor_at')->nullable();
|
||||
$table->string('source_filename')->nullable();
|
||||
$table->string('source_sha256')->nullable();
|
||||
$table->date('source_period_start')->nullable();
|
||||
$table->date('source_period_end')->nullable();
|
||||
$table->unsignedBigInteger('source_row_count')->default(0);
|
||||
$table->unsignedBigInteger('anchor_sku_count')->default(0);
|
||||
$table->dateTime('last_attempted_at')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('last_error_code')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
$schema->create('third_party_warehouse_authoritative_anchors', function (Blueprint $table): void {
|
||||
$table->string('generation');
|
||||
$table->string('sku_code');
|
||||
$table->decimal('qty', 20, 4);
|
||||
$table->dateTime('anchor_at');
|
||||
$table->dateTime('imported_at');
|
||||
$table->primary(['generation', 'sku_code']);
|
||||
});
|
||||
$schema->create('third_party_warehouse_authoritative_rows', function (Blueprint $table): void {
|
||||
$table->string('generation');
|
||||
$table->date('report_date');
|
||||
$table->string('sku_code');
|
||||
$table->decimal('opening_qty', 20, 4);
|
||||
$table->decimal('closing_qty', 20, 4);
|
||||
foreach ([
|
||||
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
|
||||
'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake',
|
||||
'transfer_out', 'transfer_in', 'opening_aux', 'other_out',
|
||||
] as $column) {
|
||||
$table->decimal($column, 20, 4)->default(0);
|
||||
}
|
||||
$table->dateTime('imported_at');
|
||||
$table->primary(['generation', 'report_date', 'sku_code']);
|
||||
});
|
||||
}
|
||||
|
||||
private function validWorkbook(): string
|
||||
{
|
||||
$rows = [$this->headers()];
|
||||
for ($day = 1; $day <= 31; $day++) {
|
||||
$rows[] = $this->row(
|
||||
sprintf('2026-08-%02d', $day),
|
||||
'SKU-A',
|
||||
10 + $day - 1,
|
||||
10 + $day,
|
||||
['采购进仓' => 1],
|
||||
);
|
||||
}
|
||||
// 聚水潭会省略部分没有变化的月末日期;每个商品取自身最后一行期末数。
|
||||
$rows[] = $this->row('2026-08-30', 'CODE_x002B_PLUS', 7, 7);
|
||||
|
||||
return $this->workbook($rows);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function headers(): array
|
||||
{
|
||||
return [
|
||||
'货主', '日期', '商品编码', '图片', '商品名称', '商品简称', '供应商',
|
||||
'分类', '虚拟分类', '供应商款号', '颜色规格', '品牌', '单位', '期初数量',
|
||||
'期末数量', '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓',
|
||||
'其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初',
|
||||
'其它出库',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,int|float> $movements @return list<string|int|float|null> */
|
||||
private function row(
|
||||
string $date,
|
||||
string $sku,
|
||||
float $opening,
|
||||
float $closing,
|
||||
array $movements = [],
|
||||
): array {
|
||||
$row = array_fill(0, 28, null);
|
||||
$row[0] = '常州简尚家居有限公司';
|
||||
$row[1] = $date;
|
||||
$row[2] = $sku;
|
||||
$row[13] = $opening;
|
||||
$row[14] = $closing;
|
||||
foreach ([
|
||||
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货',
|
||||
'销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
] as $offset => $header) {
|
||||
$row[15 + $offset] = $movements[$header] ?? 0;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** @param list<list<string|int|float|null>> $rows */
|
||||
private function workbook(array $rows): string
|
||||
{
|
||||
$temporary = tempnam(sys_get_temp_dir(), 'tpw-anchor-');
|
||||
$path = $temporary . '.xlsx';
|
||||
rename($temporary, $path);
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getActiveSheet()->fromArray($rows);
|
||||
(new Xlsx($spreadsheet))->save($path);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
$this->temporaryFiles[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageSynchronizer;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ThirdPartyWarehouseStorageSyncCommandTest extends TestCase
|
||||
{
|
||||
public function test_disabled_refresh_environment_skips_automatic_sync(): void
|
||||
{
|
||||
config(['upstream_mirrors.refresh_enabled' => false]);
|
||||
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
|
||||
$synchronizer->shouldNotReceive('sync');
|
||||
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
|
||||
|
||||
$this->artisan('business-mirror:sync-third-party-warehouse-storage')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_explicit_full_sync_and_batch_limit_are_forwarded(): void
|
||||
{
|
||||
config(['upstream_mirrors.refresh_enabled' => false]);
|
||||
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
|
||||
$synchronizer->shouldReceive('sync')->once()->with(true, 7)->andReturn([
|
||||
'status' => 'running', 'mode' => 'full', 'phase' => 'headers', 'batches' => 7,
|
||||
'headers' => 10, 'items' => 20, 'inventory' => 0,
|
||||
]);
|
||||
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
|
||||
|
||||
$this->artisan('business-mirror:sync-third-party-warehouse-storage', [
|
||||
'--full' => true,
|
||||
'--max-batches' => 7,
|
||||
])->expectsOutputToContain('全量校准:进行中')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_uninitialized_incremental_sync_prints_the_full_sync_instruction(): void
|
||||
{
|
||||
config(['upstream_mirrors.refresh_enabled' => true]);
|
||||
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
|
||||
$synchronizer->shouldReceive('sync')->once()->with(false, null)->andReturn([
|
||||
'status' => 'requires_full_sync', 'mode' => 'incremental', 'phase' => 'headers',
|
||||
'batches' => 0, 'headers' => 0, 'items' => 0, 'inventory' => 0,
|
||||
]);
|
||||
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
|
||||
|
||||
$this->artisan('business-mirror:sync-third-party-warehouse-storage')
|
||||
->expectsOutputToContain('显式使用 --full')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_concurrent_sync_is_skipped_without_reporting_a_failure(): void
|
||||
{
|
||||
config(['upstream_mirrors.refresh_enabled' => true]);
|
||||
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
|
||||
$synchronizer->shouldReceive('sync')->once()->with(false, null)->andReturn([
|
||||
'status' => 'already_running', 'mode' => 'incremental', 'phase' => 'locked',
|
||||
'batches' => 0, 'headers' => 0, 'items' => 0, 'inventory' => 0,
|
||||
]);
|
||||
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
|
||||
|
||||
$this->artisan('business-mirror:sync-third-party-warehouse-storage')
|
||||
->expectsOutputToContain('已有同步任务运行')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_sync_failure_reports_that_the_previous_complete_data_remains_available(): void
|
||||
{
|
||||
config(['upstream_mirrors.refresh_enabled' => true]);
|
||||
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
|
||||
$synchronizer->shouldReceive('sync')->once()->andThrow(new RuntimeException('offline'));
|
||||
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
|
||||
|
||||
$this->artisan('business-mirror:sync-third-party-warehouse-storage')
|
||||
->expectsOutputToContain('页面继续读取上一版完整数据')
|
||||
->assertFailed();
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,18 @@ class BusinessMirrorScheduleTest extends TestCase
|
||||
$this->assertSame(1, substr_count($contents, 'business-mirror:sync-store-report --quiet'), $script);
|
||||
$this->assertStringContainsString('docker exec -u www-data caiwu_app', $contents, $script);
|
||||
$this->assertStringContainsString('/run/caiwu-upstream-sync.lock', $contents, $script);
|
||||
$this->assertStringContainsString("CRON_TZ=Asia/Shanghai\n", $contents, $script);
|
||||
$this->assertStringContainsString('0 0 * * * root', $contents, $script);
|
||||
$this->assertStringContainsString(
|
||||
'business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet',
|
||||
$contents,
|
||||
$script,
|
||||
);
|
||||
$this->assertSame(
|
||||
1,
|
||||
substr_count($contents, 'business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet'),
|
||||
$script,
|
||||
);
|
||||
$this->assertStringContainsString('*/2 * * * * root', $contents, $script);
|
||||
$this->assertStringContainsString('esign:poll-signatures --quiet', $contents, $script);
|
||||
$this->assertStringContainsString('/run/caiwu-esign-polling.lock', $contents, $script);
|
||||
|
||||
@ -0,0 +1,698 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
{
|
||||
private const HEADER_GENERATION = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
private const ITEM_GENERATION = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
private const INVENTORY_GENERATION = '33333333-3333-3333-3333-333333333333';
|
||||
|
||||
private ThirdPartyWarehouseStorageMirrorRepository $repository;
|
||||
|
||||
private int $nextHeaderId = 1;
|
||||
|
||||
private int $nextItemId = 1;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.third_party_warehouse_storage.owner_name' => '常州简尚家居有限公司',
|
||||
'upstream_mirrors.third_party_warehouse_storage.stale_after_minutes' => 1560,
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
|
||||
$this->createMirrorTables();
|
||||
$this->createProductTable();
|
||||
$this->createDependencyStateTable();
|
||||
$this->repository = new ThirdPartyWarehouseStorageMirrorRepository(app('db'));
|
||||
}
|
||||
|
||||
public function test_it_refuses_queries_until_all_active_generations_finish_initial_sync(): void
|
||||
{
|
||||
$this->insertState([
|
||||
'status' => 'running',
|
||||
'run_mode' => 'full',
|
||||
'active_header_generation' => self::HEADER_GENERATION,
|
||||
'active_item_generation' => self::ITEM_GENERATION,
|
||||
'active_inventory_generation' => null,
|
||||
'initial_completed_at' => null,
|
||||
]);
|
||||
$this->assertTrue($this->repository->available());
|
||||
$this->assertFalse($this->repository->ready());
|
||||
$this->assertSame('initializing', $this->repository->syncStatus()['code']);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('third_party_warehouse_mirror_not_ready');
|
||||
$this->repository->paginate($this->filters(), 1, 25);
|
||||
}
|
||||
|
||||
public function test_failed_refresh_keeps_reading_only_active_generations(): void
|
||||
{
|
||||
$this->markReady(['status' => 'failed', 'last_error_code' => 'upstream_timeout']);
|
||||
$this->insertProduct('SKU-A', ['autoid' => 10]);
|
||||
$this->insertInventory('SKU-A', 6, '2026-09-01 00:00:00');
|
||||
$this->insertInventory('SKU-OLD', 99, '2026-09-01 00:00:00', [
|
||||
'generation' => '99999999-9999-9999-9999-999999999999',
|
||||
]);
|
||||
$this->insertMovement('SKU-A', '采购进仓', 1, 1, '2026-08-10 10:00:00');
|
||||
$this->insertMovement('SKU-OLD', '采购进仓', 99, 1, '2026-08-10 10:00:00', [
|
||||
'header_generation' => '99999999-9999-9999-9999-999999999999',
|
||||
'item_generation' => '99999999-9999-9999-9999-999999999999',
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate($this->filters(), 1, 25);
|
||||
|
||||
$this->assertTrue($this->repository->ready());
|
||||
$this->assertSame('failed', $result['sync']['code']);
|
||||
// 月份维度按“日期 + 商品”展开,因此 8 月对应 31 条日明细。
|
||||
$this->assertSame(31, $result['total']);
|
||||
$this->assertSame('SKU-A', $result['data'][0]['sku_code']);
|
||||
}
|
||||
|
||||
public function test_month_filter_returns_daily_rows_using_signed_movements_and_each_sku_snapshot_anchor(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-MONTH', [
|
||||
'name' => '月报商品',
|
||||
'short_name' => null,
|
||||
'supplier_name' => '供应商甲',
|
||||
'category' => '桌类',
|
||||
'purchase_price' => 30,
|
||||
]);
|
||||
$this->insertInventory('SKU-MONTH', 25, '2026-09-03 12:00:00');
|
||||
$this->insertMovement('SKU-MONTH', '采购进仓', 10, 1, '2026-08-01 00:00:00');
|
||||
$this->insertMovement('SKU-MONTH', '销售出仓', 8, -1, '2026-08-15 12:00:00');
|
||||
$this->insertMovement('SKU-MONTH', '其它进仓', 2, 1, '2026-08-31 23:59:59');
|
||||
$this->insertMovement('SKU-MONTH', '采购进仓', 5, 1, '2026-09-01 00:00:00');
|
||||
$this->insertMovement('SKU-MONTH', '销售出仓', 4, -1, '2026-09-02 00:00:00');
|
||||
// 在该 SKU 自己的快照时间之后,不能参与从快照回推历史余额。
|
||||
$this->insertMovement('SKU-MONTH', '采购进仓', 100, 1, '2026-09-04 00:00:00');
|
||||
|
||||
// 不提供统一 period 时仍兼容旧页面的 month 参数。
|
||||
$rows = $this->repository->paginate($this->filters(['period' => '']), 1, 100)['data'];
|
||||
$first = collect($rows)->firstWhere('date', '2026-08-01');
|
||||
$salesDay = collect($rows)->firstWhere('date', '2026-08-15');
|
||||
$last = collect($rows)->firstWhere('date', '2026-08-31');
|
||||
|
||||
$this->assertCount(31, $rows);
|
||||
$this->assertSame('常州简尚家居有限公司', $first['owner']);
|
||||
$this->assertSame('月报商品', $first['short_name']);
|
||||
$this->assertSame(10.0, $first['purchase_in']);
|
||||
$this->assertSame(20.0, $first['opening_qty']);
|
||||
$this->assertSame(30.0, $first['closing_qty']);
|
||||
$this->assertSame(-8.0, $salesDay['sales_out']);
|
||||
$this->assertSame(30.0, $salesDay['opening_qty']);
|
||||
$this->assertSame(22.0, $salesDay['closing_qty']);
|
||||
$this->assertSame(2.0, $last['other_in']);
|
||||
$this->assertSame(22.0, $last['opening_qty']);
|
||||
$this->assertSame(24.0, $last['closing_qty']);
|
||||
$this->assertCount(28, $first);
|
||||
|
||||
$this->insertProduct('SKU-EARLY', ['autoid' => 20, 'purchase_price' => 2]);
|
||||
$this->insertInventory('SKU-EARLY', 10, '2026-08-10 00:00:00');
|
||||
$this->insertMovement('SKU-EARLY', '采购进仓', 5, 1, '2026-08-20 00:00:00');
|
||||
$early = collect($this->repository->paginate($this->filters([
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-08-20',
|
||||
]), 1, 25)['data'])->firstWhere('sku_code', 'SKU-EARLY');
|
||||
|
||||
$this->assertSame(10.0, $early['opening_qty']);
|
||||
$this->assertSame(15.0, $early['closing_qty']);
|
||||
}
|
||||
|
||||
public function test_periods_before_the_supported_start_date_are_clamped(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-CUTOFF');
|
||||
$this->insertInventory('SKU-CUTOFF', 2, '2026-08-02 00:00:00');
|
||||
$this->insertMovement('SKU-CUTOFF', '采购进仓', 2, 1, '2026-08-01 12:00:00');
|
||||
|
||||
$day = $this->repository->paginate($this->filters([
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-07-01',
|
||||
]), 1, 25)['data'][0];
|
||||
$month = $this->repository->paginate($this->filters(['period' => '2026-07']), 1, 100)['data'];
|
||||
|
||||
$this->assertSame('2026-08-01', $day['date']);
|
||||
$this->assertSame('2026-08-01', $month[0]['date']);
|
||||
$this->assertSame('2026-08-31', $month[30]['date']);
|
||||
}
|
||||
|
||||
public function test_product_catalog_must_finish_initial_sync_before_product_fields_are_exposed(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')
|
||||
->where('dataset_key', 'operations_replica.rpt.jst_sku')
|
||||
->update(['initial_completed_at' => null, 'status' => 'running']);
|
||||
|
||||
$this->assertFalse($this->repository->ready());
|
||||
$this->assertSame('dependency_pending', $this->repository->syncStatus()['code']);
|
||||
}
|
||||
|
||||
public function test_product_join_only_reads_normal_product_rows(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-SCOPE', [
|
||||
'autoid' => 10,
|
||||
'name' => '普通商品',
|
||||
]);
|
||||
$this->insertProduct('SKU-SCOPE', [
|
||||
'autoid' => 10,
|
||||
'sku_type' => 'combine',
|
||||
'name' => '组合商品',
|
||||
]);
|
||||
$this->insertInventory('SKU-SCOPE', 1, '2026-08-15 12:00:00');
|
||||
|
||||
$result = $this->repository->paginate($this->filters([
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-08-15',
|
||||
]), 1, 25);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertCount(1, $result['data']);
|
||||
$this->assertSame('普通商品', $result['data'][0]['product_name']);
|
||||
}
|
||||
|
||||
public function test_summary_without_product_filters_does_not_join_the_product_catalog(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-SUMMARY');
|
||||
$this->insertInventory('SKU-SUMMARY', 1, '2026-08-15 12:00:00');
|
||||
$queries = [];
|
||||
DB::connection('business_mirror')->listen(function ($query) use (&$queries): void {
|
||||
$queries[] = $query->sql;
|
||||
});
|
||||
|
||||
$summary = $this->repository->summary($this->filters());
|
||||
|
||||
$this->assertSame(31, $summary['count']);
|
||||
$this->assertFalse(collect($queries)->contains(
|
||||
static fn (string $sql): bool => str_contains($sql, 'ops_jst_sku'),
|
||||
));
|
||||
}
|
||||
|
||||
public function test_day_dimension_uses_half_open_boundary(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-DAY', ['name' => '按天商品', 'purchase_price' => 10]);
|
||||
$this->insertInventory('SKU-DAY', 22, '2026-08-17 00:00:00');
|
||||
$this->insertMovement('SKU-DAY', '采购进仓', 10, 1, '2026-08-15 00:00:00');
|
||||
$this->insertMovement('SKU-DAY', '销售出仓', 4, -1, '2026-08-15 23:59:59');
|
||||
// 次日零点不属于 8 月 15 日,但属于日末到库存锚点之间的回推区间。
|
||||
$this->insertMovement('SKU-DAY', '采购进仓', 3, 1, '2026-08-16 00:00:00');
|
||||
|
||||
$row = $this->repository->paginate($this->filters([
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-08-15',
|
||||
'month' => '',
|
||||
]), 1, 25)['data'][0];
|
||||
|
||||
$this->assertSame(10.0, $row['purchase_in']);
|
||||
$this->assertSame(-4.0, $row['sales_out']);
|
||||
$this->assertSame(19.0, $row['closing_qty']);
|
||||
$this->assertSame(13.0, $row['opening_qty']);
|
||||
}
|
||||
|
||||
public function test_authoritative_rows_override_historical_stream_and_anchor_rolls_into_september(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-A');
|
||||
// 实时库存会持续变化;存在权威月末锚点后不能再用它回推历史。
|
||||
$this->insertInventory('SKU-A', 999, '2026-09-11 03:21:00');
|
||||
$this->insertAuthoritativeAnchor('SKU-A', 20, '2026-09-01 00:00:00');
|
||||
$this->insertAuthoritativeRow('SKU-A', '2026-08-01', 17, 22, ['purchase_in' => 5]);
|
||||
$this->insertAuthoritativeRow('SKU-A', '2026-08-24', 22, 20, ['sales_out' => -2]);
|
||||
$this->insertAuthoritativeRow('SKU-A', '2026-08-31', 20, 20);
|
||||
$this->insertMovement('SKU-A', '采购进仓', 5, 1, '2026-08-01 00:00:00');
|
||||
$this->insertMovement('SKU-A', '销售出仓', 2, -1, '2026-08-24 12:00:00', [
|
||||
'status' => 'OuterConfirming',
|
||||
]);
|
||||
$this->insertMovement('SKU-A', '采购进仓', 3, 1, '2026-09-01 00:00:00');
|
||||
|
||||
$august = $this->repository->paginate($this->filters(), 1, 100)['data'];
|
||||
$augustFirst = collect($august)->firstWhere('date', '2026-08-01');
|
||||
$augustOuterConfirming = collect($august)->firstWhere('date', '2026-08-24');
|
||||
$augustLast = collect($august)->firstWhere('date', '2026-08-31');
|
||||
$septemberFirst = $this->repository->paginate($this->filters([
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-09-01',
|
||||
]), 1, 25)['data'][0];
|
||||
|
||||
$this->assertSame(17.0, $augustFirst['opening_qty']);
|
||||
$this->assertSame(22.0, $augustFirst['closing_qty']);
|
||||
$this->assertSame(-2.0, $augustOuterConfirming['sales_out']);
|
||||
$this->assertSame(20.0, $augustOuterConfirming['closing_qty']);
|
||||
$this->assertSame(20.0, $augustLast['closing_qty']);
|
||||
$this->assertSame(20.0, $septemberFirst['opening_qty']);
|
||||
$this->assertSame(3.0, $septemberFirst['purchase_in']);
|
||||
$this->assertSame(23.0, $septemberFirst['closing_qty']);
|
||||
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_anchor_states')
|
||||
->update(['status' => 'failed']);
|
||||
$afterFailedReplacement = $this->repository->paginate($this->filters([
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-08-31',
|
||||
]), 1, 25)['data'][0];
|
||||
$this->assertSame(20.0, $afterFailedReplacement['closing_qty']);
|
||||
}
|
||||
|
||||
public function test_it_maps_all_twelve_types_and_excludes_wrong_scope_and_unknown_types(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-TYPES');
|
||||
$this->insertInventory('SKU-TYPES', 50, '2026-09-01 00:00:00');
|
||||
$types = [
|
||||
'加工进仓' => [1, 1],
|
||||
'加工出仓' => [2, -1],
|
||||
'采购进仓' => [3, 1],
|
||||
'采购退货' => [4, -1],
|
||||
'其它进仓' => [5, 1],
|
||||
'其它退货' => [6, -1],
|
||||
'销售出仓' => [7, -1],
|
||||
'销售退货' => [8, 1],
|
||||
'盘点' => [9, -1],
|
||||
'调拨出' => [10, -1],
|
||||
'调拨入' => [11, 1],
|
||||
'其它出库' => [12, -1],
|
||||
];
|
||||
foreach ($types as $type => [$qty, $qtyType]) {
|
||||
$this->insertMovement('SKU-TYPES', $type, $qty, $qtyType, '2026-08-20 12:00:00');
|
||||
}
|
||||
$this->insertMovement('SKU-TYPES', '维修', 500, -1, '2026-08-20 12:00:00');
|
||||
$this->insertMovement('SKU-TYPES', '虚拟仓调拨入', 500, 1, '2026-08-20 12:00:00');
|
||||
$this->insertMovement('SKU-TYPES', '装箱快递', 500, -1, '2026-08-20 12:00:00');
|
||||
$this->insertMovement('SKU-TYPES', '采购进仓', 500, 1, '2026-08-20 12:00:00', ['status' => 'WaitConfirm']);
|
||||
$this->insertMovement('SKU-TYPES', '采购进仓', 500, 1, '2026-08-20 12:00:00', ['warehouse_id' => 999]);
|
||||
|
||||
// 同一单据出现更新版本时只认最新头记录,避免重复累计旧版本。
|
||||
$this->insertMovement('SKU-TYPES', '采购进仓', 600, 1, '2026-08-20 12:00:00', [
|
||||
'io_id' => 9000,
|
||||
'header_id' => 9000,
|
||||
]);
|
||||
$this->insertHeaderOnly(9001, 9000, '采购进仓', '2026-08-20 12:00:00', 'WaitConfirm');
|
||||
|
||||
$row = $this->repository->paginate($this->filters([
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-08-20',
|
||||
]), 1, 25)['data'][0];
|
||||
|
||||
$expected = [
|
||||
'processing_in' => 1.0,
|
||||
'processing_out' => -2.0,
|
||||
'purchase_in' => 3.0,
|
||||
'purchase_return' => -4.0,
|
||||
'other_in' => 5.0,
|
||||
'other_return' => -6.0,
|
||||
'sales_out' => -7.0,
|
||||
'sales_return' => 8.0,
|
||||
'stocktake' => -9.0,
|
||||
'transfer_out' => -10.0,
|
||||
'transfer_in' => 11.0,
|
||||
'other_out' => -12.0,
|
||||
];
|
||||
foreach ($expected as $column => $value) {
|
||||
$this->assertSame($value, $row[$column], $column);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_filters_paging_summary_options_and_export_share_the_non_zero_scope(): void
|
||||
{
|
||||
$this->markReady();
|
||||
foreach ([
|
||||
['SKU-C', '椅子 C', '供应商乙', '椅类', 3],
|
||||
['SKU-A', '桌子 A', '供应商甲', '桌类', 1],
|
||||
['SKU-B', '桌子 B', '供应商甲', '桌类', 2],
|
||||
] as [$sku, $name, $supplier, $category, $qty]) {
|
||||
$this->insertProduct($sku, [
|
||||
'name' => $name,
|
||||
'supplier_name' => $supplier,
|
||||
'category' => $category,
|
||||
]);
|
||||
$this->insertInventory($sku, $qty, '2026-09-01 00:00:00');
|
||||
}
|
||||
$this->insertProduct('SKU-ZERO', ['name' => '零库存', 'supplier_name' => '供应商甲', 'category' => '桌类']);
|
||||
$this->insertInventory('SKU-ZERO', 0, '2026-09-01 00:00:00');
|
||||
// 没有库存快照但存在相关流水的 SKU 也必须进入候选集合。
|
||||
$this->insertProduct('SKU-FLOW', ['name' => '桌子流水', 'supplier_name' => '供应商甲', 'category' => '桌类']);
|
||||
$this->insertMovement('SKU-FLOW', '销售出仓', 2, -1, '2026-08-10 00:00:00');
|
||||
$this->insertMovement('SKU-FLOW', '销售出仓', 3, -1, '2026-09-02 00:00:00');
|
||||
|
||||
$filters = $this->filters([
|
||||
'dimension' => 'day',
|
||||
'period' => '2026-08-10',
|
||||
'product_name' => '桌子',
|
||||
'supplier' => '供应商甲',
|
||||
'category' => '桌类',
|
||||
]);
|
||||
$pageOne = $this->repository->paginate($filters, 1, 1);
|
||||
$pageTwo = $this->repository->paginate($filters, 2, 1);
|
||||
$summary = $this->repository->summary($filters);
|
||||
$options = $this->repository->filterOptions($this->filters(['product_name' => '桌子']));
|
||||
$export = iterator_to_array($this->repository->exportRows($filters, 1), false);
|
||||
|
||||
$this->assertSame(3, $pageOne['total']);
|
||||
$this->assertSame('SKU-A', $pageOne['data'][0]['sku_code']);
|
||||
$this->assertSame('SKU-B', $pageTwo['data'][0]['sku_code']);
|
||||
$this->assertSame([
|
||||
'count' => 3,
|
||||
'opening_qty' => 8.0,
|
||||
'closing_qty' => 6.0,
|
||||
], $summary);
|
||||
$this->assertSame(['供应商甲'], $options['suppliers']);
|
||||
$this->assertSame(['桌类'], $options['categories']);
|
||||
$this->assertSame(['SKU-A', 'SKU-B', 'SKU-FLOW'], array_column($export, 'sku_code'));
|
||||
$this->assertSame(31, $this->repository->summary($this->filters(['sku_code' => 'SKU-B']))['count']);
|
||||
}
|
||||
|
||||
public function test_export_keeps_its_initial_generation_when_publication_switches_between_batches(): void
|
||||
{
|
||||
$this->markReady();
|
||||
foreach (['SKU-A', 'SKU-B'] as $sku) {
|
||||
$this->insertProduct($sku);
|
||||
$this->insertInventory($sku, 1, '2026-09-01 00:00:00');
|
||||
}
|
||||
|
||||
$export = $this->repository->exportRows($this->filters(), 1);
|
||||
$this->assertTrue($export->valid());
|
||||
$this->assertSame('SKU-A', $export->current()['sku_code']);
|
||||
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->update([
|
||||
'active_header_generation' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'active_item_generation' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'active_inventory_generation' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
]);
|
||||
$export->next();
|
||||
|
||||
$this->assertTrue($export->valid());
|
||||
$this->assertSame('SKU-B', $export->current()['sku_code']);
|
||||
}
|
||||
|
||||
private function createMirrorTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status')->nullable();
|
||||
$table->string('run_mode')->nullable();
|
||||
$table->string('active_header_generation')->nullable();
|
||||
$table->string('active_item_generation')->nullable();
|
||||
$table->string('active_inventory_generation')->nullable();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('last_error_code')->nullable();
|
||||
});
|
||||
$schema->create('third_party_warehouse_inout_headers', function (Blueprint $table): void {
|
||||
$table->id('_mirror_id');
|
||||
$table->string('generation');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->string('status')->nullable();
|
||||
$table->string('io_type')->nullable();
|
||||
$table->dateTime('occurred_at')->nullable();
|
||||
$table->dateTime('source_modified_at')->nullable();
|
||||
});
|
||||
$schema->create('third_party_warehouse_inout_items', function (Blueprint $table): void {
|
||||
$table->id('_mirror_id');
|
||||
$table->string('generation');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->string('sku_code');
|
||||
$table->decimal('qty', 20, 4);
|
||||
$table->smallInteger('qty_type');
|
||||
$table->dateTime('source_modified_at')->nullable();
|
||||
});
|
||||
$schema->create('third_party_warehouse_inventory_snapshots', function (Blueprint $table): void {
|
||||
$table->id('_mirror_id');
|
||||
$table->string('generation');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->string('sku_code');
|
||||
$table->decimal('qty', 20, 4);
|
||||
$table->dateTime('source_modified_at')->nullable();
|
||||
$table->dateTime('captured_at')->nullable();
|
||||
});
|
||||
$schema->create('third_party_warehouse_authoritative_anchor_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status');
|
||||
$table->string('active_generation')->nullable();
|
||||
$table->dateTime('anchor_at')->nullable();
|
||||
$table->date('source_period_start')->nullable();
|
||||
$table->date('source_period_end')->nullable();
|
||||
});
|
||||
$schema->create('third_party_warehouse_authoritative_anchors', function (Blueprint $table): void {
|
||||
$table->string('generation');
|
||||
$table->string('sku_code');
|
||||
$table->decimal('qty', 20, 4);
|
||||
$table->dateTime('anchor_at');
|
||||
$table->primary(['generation', 'sku_code']);
|
||||
});
|
||||
$schema->create('third_party_warehouse_authoritative_rows', function (Blueprint $table): void {
|
||||
$table->string('generation');
|
||||
$table->date('report_date');
|
||||
$table->string('sku_code');
|
||||
foreach ([
|
||||
'opening_qty', 'closing_qty', 'processing_in', 'processing_out',
|
||||
'purchase_in', 'purchase_return', 'other_in', 'other_return',
|
||||
'sales_out', 'sales_return', 'stocktake', 'transfer_out',
|
||||
'transfer_in', 'opening_aux', 'other_out',
|
||||
] as $column) {
|
||||
$table->decimal($column, 20, 4)->default(0);
|
||||
}
|
||||
$table->primary(['generation', 'report_date', 'sku_code']);
|
||||
});
|
||||
}
|
||||
|
||||
private function createProductTable(): void
|
||||
{
|
||||
Schema::connection('business_mirror')->create('ops_jst_sku', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('autoid');
|
||||
$table->string('sku_id')->nullable();
|
||||
$table->string('sku_type')->nullable();
|
||||
foreach ([
|
||||
'pic_big', 'pic', 'name', 'short_name', 'supplier_name', 'category',
|
||||
'vc_name', 'supplier_sku_id', 'properties_value', 'color', 'brand',
|
||||
'unit', 'other_price_1', 'other_price_2', 'other_price_3',
|
||||
'other_price_4', 'other_1', 'other_2', 'other_3', 'other_4',
|
||||
'other_5', 'other_6', 'other_7', 'other_8', 'other_9', 'other_10',
|
||||
] as $column) {
|
||||
$table->string($column)->nullable();
|
||||
}
|
||||
$table->decimal('pack_qty', 20, 4)->nullable();
|
||||
$table->decimal('purchase_price', 20, 4)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
private function createDependencyStateTable(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status')->nullable();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
});
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
[
|
||||
'dataset_key' => 'operations_replica.rpt.jst_sku',
|
||||
'status' => 'succeeded',
|
||||
'initial_completed_at' => '2026-09-01 03:00:00',
|
||||
'last_succeeded_at' => '2026-09-11 03:00:00',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $overrides */
|
||||
private function markReady(array $overrides = []): void
|
||||
{
|
||||
$this->insertState(array_merge([
|
||||
'status' => 'succeeded',
|
||||
'run_mode' => null,
|
||||
'active_header_generation' => self::HEADER_GENERATION,
|
||||
'active_item_generation' => self::ITEM_GENERATION,
|
||||
'active_inventory_generation' => self::INVENTORY_GENERATION,
|
||||
'initial_completed_at' => '2026-09-11 03:20:00',
|
||||
'last_succeeded_at' => '2026-09-11 03:21:00',
|
||||
'last_error_code' => null,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $values */
|
||||
private function insertState(array $values): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->insert(
|
||||
['dataset_key' => ThirdPartyWarehouseStorageMirrorRepository::DATASET_KEY] + $values,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $overrides */
|
||||
private function insertProduct(string $sku, array $overrides = []): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('ops_jst_sku')->insert(array_merge([
|
||||
'autoid' => 1,
|
||||
'sku_id' => $sku,
|
||||
'sku_type' => 'normal',
|
||||
'pic_big' => 'https://example.test/' . $sku . '.jpg',
|
||||
'pic' => null,
|
||||
'name' => '测试商品',
|
||||
'short_name' => '测试简称',
|
||||
'supplier_name' => '测试供应商',
|
||||
'category' => '测试分类',
|
||||
'vc_name' => '测试虚拟分类',
|
||||
'supplier_sku_id' => 'SUP-' . $sku,
|
||||
'properties_value' => '原木色',
|
||||
'color' => null,
|
||||
'brand' => '测试品牌',
|
||||
'unit' => '件',
|
||||
'purchase_price' => 55,
|
||||
'other_price_1' => '采购价',
|
||||
'other_price_2' => '按需叫货',
|
||||
'other_price_3' => null,
|
||||
'other_price_4' => null,
|
||||
'pack_qty' => 1,
|
||||
'other_1' => '型号',
|
||||
'other_2' => '板材',
|
||||
'other_3' => '采购',
|
||||
'other_4' => null,
|
||||
'other_5' => '在产',
|
||||
'other_6' => null,
|
||||
'other_7' => 'ITEM-' . $sku,
|
||||
'other_8' => '单包',
|
||||
'other_9' => 'DRAW-' . $sku,
|
||||
'other_10' => '木',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $overrides */
|
||||
private function insertInventory(string $sku, float $qty, string $capturedAt, array $overrides = []): void
|
||||
{
|
||||
static $id = 1;
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert(array_merge([
|
||||
'generation' => self::INVENTORY_GENERATION,
|
||||
'id' => $id++,
|
||||
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
|
||||
'sku_code' => $sku,
|
||||
'qty' => $qty,
|
||||
'captured_at' => $capturedAt,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
private function insertAuthoritativeAnchor(string $sku, float $qty, string $anchorAt): void
|
||||
{
|
||||
$generation = '44444444-4444-4444-4444-444444444444';
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_anchor_states')->insert([
|
||||
'dataset_key' => 'jiansun.third-party-warehouse-storage.authoritative-anchor',
|
||||
'status' => 'succeeded',
|
||||
'active_generation' => $generation,
|
||||
'anchor_at' => $anchorAt,
|
||||
'source_period_start' => '2026-08-01',
|
||||
'source_period_end' => '2026-08-31',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_anchors')->insert([
|
||||
'generation' => $generation,
|
||||
'sku_code' => $sku,
|
||||
'qty' => $qty,
|
||||
'anchor_at' => $anchorAt,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,float|int> $movements */
|
||||
private function insertAuthoritativeRow(
|
||||
string $sku,
|
||||
string $date,
|
||||
float $opening,
|
||||
float $closing,
|
||||
array $movements = [],
|
||||
): void {
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_rows')->insert(array_merge([
|
||||
'generation' => '44444444-4444-4444-4444-444444444444',
|
||||
'report_date' => $date,
|
||||
'sku_code' => $sku,
|
||||
'opening_qty' => $opening,
|
||||
'closing_qty' => $closing,
|
||||
], $movements));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $overrides */
|
||||
private function insertMovement(
|
||||
string $sku,
|
||||
string $type,
|
||||
float $qty,
|
||||
int $qtyType,
|
||||
string $occurredAt,
|
||||
array $overrides = [],
|
||||
): void {
|
||||
$ioId = $overrides['io_id'] ?? (1000 + $this->nextHeaderId);
|
||||
$headerGeneration = $overrides['header_generation'] ?? self::HEADER_GENERATION;
|
||||
$itemGeneration = $overrides['item_generation'] ?? self::ITEM_GENERATION;
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
|
||||
'generation' => $headerGeneration,
|
||||
'id' => $overrides['header_id'] ?? $this->nextHeaderId++,
|
||||
'io_id' => $ioId,
|
||||
'wms_co_id' => $overrides['warehouse_id'] ?? ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
|
||||
'status' => $overrides['status'] ?? 'Confirmed',
|
||||
'io_type' => $type,
|
||||
'occurred_at' => $occurredAt,
|
||||
]);
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inout_items')->insert([
|
||||
'generation' => $itemGeneration,
|
||||
'id' => $this->nextItemId++,
|
||||
'io_id' => $ioId,
|
||||
'sku_code' => $sku,
|
||||
'qty' => $qty,
|
||||
'qty_type' => $qtyType,
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertHeaderOnly(
|
||||
int $id,
|
||||
int $ioId,
|
||||
string $type,
|
||||
string $occurredAt,
|
||||
string $status,
|
||||
): void {
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
|
||||
'generation' => self::HEADER_GENERATION,
|
||||
'id' => $id,
|
||||
'io_id' => $ioId,
|
||||
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
|
||||
'status' => $status,
|
||||
'io_type' => $type,
|
||||
'occurred_at' => $occurredAt,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $overrides */
|
||||
private function filters(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'dimension' => 'month',
|
||||
'period' => '2026-08',
|
||||
'month' => '2026-08',
|
||||
'date' => '',
|
||||
'sku_code' => '',
|
||||
'product_name' => '',
|
||||
'supplier' => '',
|
||||
'category' => '',
|
||||
], $overrides);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,720 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Cache\CacheManager;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageUpstreamRepository;
|
||||
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageSynchronizer;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
|
||||
{
|
||||
private FakeThirdPartyWarehouseUpstreamRepository $upstream;
|
||||
|
||||
private ThirdPartyWarehouseStorageSynchronizer $synchronizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.third_party_warehouse_storage' => [
|
||||
'header_scan_size' => 100,
|
||||
'max_header_batches_per_run' => 10,
|
||||
'inventory_batch_size' => 5,
|
||||
'incremental_batch_size' => 2,
|
||||
'incremental_overlap_hours' => 48,
|
||||
'generation_retention_hours' => 48,
|
||||
],
|
||||
'upstreams.snapshots.cache_store' => 'array',
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$this->createMirrorSchema();
|
||||
|
||||
$this->upstream = new FakeThirdPartyWarehouseUpstreamRepository();
|
||||
$this->synchronizer = new ThirdPartyWarehouseStorageSynchronizer(
|
||||
$this->upstream,
|
||||
app('db'),
|
||||
app(CacheManager::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_shared_lock_prevents_a_second_sync_from_replacing_the_pending_generation(): void
|
||||
{
|
||||
$lock = app(CacheManager::class)->store('array')->lock(
|
||||
ThirdPartyWarehouseStorageSynchronizer::LOCK_KEY,
|
||||
60,
|
||||
);
|
||||
$this->assertTrue($lock->get());
|
||||
|
||||
try {
|
||||
$result = $this->synchronizer->sync(true, 0, 100);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
$this->assertSame('already_running', $result['status']);
|
||||
$this->assertSame('locked', $result['phase']);
|
||||
$this->assertSame([], $this->upstream->headerCalls);
|
||||
$this->assertSame(0, DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_sync_states')->count());
|
||||
}
|
||||
|
||||
public function test_empty_header_id_interval_still_advances_the_checkpoint(): void
|
||||
{
|
||||
$this->upstream->headerHighWatermark = 150;
|
||||
|
||||
$result = $this->synchronizer->sync(true, 1, 100);
|
||||
|
||||
$this->assertSame('running', $result['status']);
|
||||
$this->assertSame('headers', $result['phase']);
|
||||
$this->assertSame([[0, 100]], $this->upstream->headerCalls);
|
||||
$state = $this->state();
|
||||
$this->assertSame(100, (int) $state->header_cursor);
|
||||
$this->assertSame('full', $state->run_mode);
|
||||
}
|
||||
|
||||
public function test_interrupted_full_sync_resumes_with_the_same_generation_and_activates_only_when_complete(): void
|
||||
{
|
||||
$this->upstream->headerHighWatermark = 150;
|
||||
$this->upstream->inventoryHighWatermark = 20;
|
||||
$this->upstream->headerResolver = static fn (int $after): array => $after === 100 ? [[
|
||||
'source_id' => 120,
|
||||
'io_id' => 9001,
|
||||
'wms_co_id' => 15045364,
|
||||
'status' => 'Confirmed',
|
||||
'io_type' => '采购进仓',
|
||||
'occurred_at' => '2026-08-10T10:00:00.000',
|
||||
'modified_at' => '2026-08-10T10:01:00.000',
|
||||
]] : [];
|
||||
$this->upstream->itemResolver = static fn (array $ioIds): array => $ioIds === [9001] ? [[
|
||||
'source_id' => 501,
|
||||
'io_id' => 9001,
|
||||
'sku_code' => str_repeat('S', 200),
|
||||
'qty' => 3,
|
||||
'qty_type' => -1,
|
||||
]] : [];
|
||||
$this->upstream->inventoryResolver = static fn (): array => [[
|
||||
'source_id' => 15,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'SKU-1',
|
||||
'qty' => 8,
|
||||
'source_modified_at' => '2026-09-11T03:19:00.000',
|
||||
]];
|
||||
|
||||
$first = $this->synchronizer->sync(true, 1, 100);
|
||||
$interruptedState = $this->state();
|
||||
$pendingGeneration = $interruptedState->pending_header_generation;
|
||||
$second = $this->synchronizer->sync(false, 0, 100);
|
||||
|
||||
$this->assertSame('running', $first['status']);
|
||||
$this->assertNull($interruptedState->active_header_generation);
|
||||
$this->assertNull($interruptedState->active_item_generation);
|
||||
$this->assertNull($interruptedState->active_inventory_generation);
|
||||
$this->assertSame('succeeded', $second['status']);
|
||||
$this->assertSame('full', $second['mode']);
|
||||
$this->assertSame($pendingGeneration, $this->state()->active_header_generation);
|
||||
$this->assertNull($this->state()->pending_header_generation);
|
||||
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->count());
|
||||
$this->assertSame(191, mb_strlen((string) DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_inout_items')->value('sku_code')));
|
||||
$this->assertSame(-1, (int) DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_inout_items')->value('qty_type'));
|
||||
}
|
||||
|
||||
public function test_inventory_snapshot_switches_generation_after_a_complete_read(): void
|
||||
{
|
||||
$this->seedReadyMirror();
|
||||
$this->upstream->headerHighWatermark = 100000;
|
||||
$this->upstream->inventoryHighWatermark = 200;
|
||||
$this->upstream->inventoryResolver = static fn (): array => [[
|
||||
'source_id' => 125,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'NEW-1',
|
||||
'qty' => 12,
|
||||
'source_modified_at' => '2026-09-11T03:19:00.000',
|
||||
], [
|
||||
'source_id' => 190,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'NEW-2',
|
||||
'qty' => 4,
|
||||
'source_modified_at' => '2026-09-11T03:19:00.000',
|
||||
]];
|
||||
|
||||
$result = $this->synchronizer->sync(false, 0, 100);
|
||||
|
||||
$state = $this->state();
|
||||
$this->assertSame('succeeded', $result['status']);
|
||||
$this->assertSame('incremental', $result['mode']);
|
||||
$this->assertSame(100000, (int) $state->header_cursor);
|
||||
$this->assertNotSame('inventory-old', $state->active_inventory_generation);
|
||||
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')
|
||||
->where('generation', 'inventory-old')->count());
|
||||
$this->assertSame(['NEW-1', 'NEW-2'], DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_inventory_snapshots')
|
||||
->where('generation', $state->active_inventory_generation)
|
||||
->orderBy('id')->pluck('sku_code')->all());
|
||||
$this->assertSame([[0, 200, 5]], $this->upstream->inventoryCalls);
|
||||
}
|
||||
|
||||
public function test_inventory_keyset_checkpoint_resumes_at_the_last_received_target_row(): void
|
||||
{
|
||||
$this->seedReadyMirror();
|
||||
$this->upstream->headerHighWatermark = 100000;
|
||||
$this->upstream->inventoryHighWatermark = 200;
|
||||
$this->upstream->inventoryResolver = static fn (int $after): array => $after === 0
|
||||
? array_map(static fn (int $id): array => [
|
||||
'source_id' => $id,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'SKU-' . $id,
|
||||
'qty' => 1,
|
||||
'source_modified_at' => '2026-09-11T03:19:00.000',
|
||||
], [10, 20, 30, 40, 50])
|
||||
: [[
|
||||
'source_id' => 190,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'SKU-190',
|
||||
'qty' => 2,
|
||||
'source_modified_at' => '2026-09-11T03:19:00.000',
|
||||
]];
|
||||
|
||||
$first = $this->synchronizer->sync(false, 1, 100);
|
||||
$pendingGeneration = $this->state()->pending_inventory_generation;
|
||||
$this->assertSame(50, (int) $this->state()->inventory_cursor);
|
||||
$this->assertSame(200, (int) $this->state()->inventory_high_watermark);
|
||||
$second = $this->synchronizer->sync(false, 0, 100);
|
||||
|
||||
$this->assertSame('running', $first['status']);
|
||||
$this->assertSame('inventory', $first['phase']);
|
||||
$this->assertSame('succeeded', $second['status']);
|
||||
$this->assertSame($pendingGeneration, $this->state()->active_inventory_generation);
|
||||
$this->assertSame([[0, 200, 5], [50, 200, 5]], $this->upstream->inventoryCalls);
|
||||
}
|
||||
|
||||
public function test_inventory_failure_keeps_the_previous_active_generation(): void
|
||||
{
|
||||
$this->seedReadyMirror();
|
||||
$this->upstream->headerHighWatermark = 100000;
|
||||
$this->upstream->inventoryHighWatermark = 200;
|
||||
$this->upstream->inventoryFailure = new RuntimeException('upstream unavailable');
|
||||
|
||||
try {
|
||||
$this->synchronizer->sync(false, 0, 100);
|
||||
$this->fail('库存读取异常必须向命令层抛出。');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertSame('upstream unavailable', $exception->getMessage());
|
||||
}
|
||||
|
||||
$state = $this->state();
|
||||
$this->assertSame('failed', $state->status);
|
||||
$this->assertSame('inventory-old', $state->active_inventory_generation);
|
||||
$this->assertNotNull($state->pending_inventory_generation);
|
||||
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')
|
||||
->where('generation', 'inventory-old')->count());
|
||||
}
|
||||
|
||||
public function test_incremental_sync_uses_a_resumable_modified_and_id_cursor(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-09-11 03:20:00');
|
||||
$this->seedReadyMirror();
|
||||
$this->upstream->modifiedHeaderResolver = static fn (): array => [[
|
||||
'source_id' => 700,
|
||||
'io_id' => 700,
|
||||
'wms_co_id' => 15045364,
|
||||
'status' => 'Confirmed',
|
||||
'io_type' => '采购进仓',
|
||||
'occurred_at' => '2026-09-10T10:00:00.000',
|
||||
'modified_at' => '2026-09-10T10:01:00.000',
|
||||
], [
|
||||
'source_id' => 701,
|
||||
'io_id' => 701,
|
||||
'wms_co_id' => 15045364,
|
||||
'status' => 'Confirmed',
|
||||
'io_type' => '采购进仓',
|
||||
'occurred_at' => '2026-09-10T10:00:00.000',
|
||||
'modified_at' => '2026-09-10T10:01:00.000',
|
||||
]];
|
||||
|
||||
$result = $this->synchronizer->sync(false, 1, 100);
|
||||
|
||||
$this->assertSame('running', $result['status']);
|
||||
$this->assertSame([
|
||||
['2026-09-09 03:20:00.000', 0, '2026-09-11 03:20:00.000', 2],
|
||||
], $this->upstream->modifiedHeaderCalls);
|
||||
$this->assertSame('2026-09-10 10:01:00.000', $this->state()->header_modified_cursor_at);
|
||||
$this->assertSame(701, (int) $this->state()->header_modified_cursor_id);
|
||||
$this->assertSame('2026-09-11 03:20:00.000', $this->state()->header_window_ends_at);
|
||||
$this->assertSame('header-old', $this->state()->active_header_generation);
|
||||
$this->assertSame('item-old', $this->state()->active_item_generation);
|
||||
$this->assertSame('inventory-old', $this->state()->active_inventory_generation);
|
||||
$this->assertNotSame('header-old', $this->state()->pending_header_generation);
|
||||
$this->assertSame(0, DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_inout_headers')
|
||||
->where('generation', 'header-old')->where('id', 700)->count());
|
||||
}
|
||||
|
||||
public function test_incremental_overlap_removes_rows_that_left_the_target_warehouse(): void
|
||||
{
|
||||
$this->seedReadyMirror();
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->table('third_party_warehouse_inout_headers')->insert([
|
||||
'generation' => 'header-old', 'id' => 50050, 'io_id' => 50050,
|
||||
'wms_co_id' => 15045364, 'status' => 'Confirmed', 'io_type' => '采购进仓',
|
||||
'row_hash' => str_repeat('d', 64), 'synced_at' => now(),
|
||||
]);
|
||||
$db->table('third_party_warehouse_inout_items')->insert([
|
||||
'generation' => 'item-old', 'id' => 50050, 'io_id' => 50050,
|
||||
'sku_code' => 'MOVED-AWAY', 'qty' => 2, 'qty_type' => 1,
|
||||
'row_hash' => str_repeat('e', 64), 'synced_at' => now(),
|
||||
]);
|
||||
$this->upstream->modifiedHeaderResolver = static fn (): array => [[
|
||||
'source_id' => 60000,
|
||||
'io_id' => 50050,
|
||||
'wms_co_id' => 999,
|
||||
'status' => 'Confirmed',
|
||||
'io_type' => '采购进仓',
|
||||
'occurred_at' => '2026-09-10T10:00:00.000',
|
||||
'modified_at' => now()->subHour()->format('Y-m-d\\TH:i:s.u'),
|
||||
]];
|
||||
$this->upstream->inventoryHighWatermark = 1;
|
||||
$this->upstream->inventoryResolver = static fn (): array => [[
|
||||
'source_id' => 1,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'OLD-1',
|
||||
'qty' => 1,
|
||||
'source_modified_at' => now()->subMinute()->format('Y-m-d\\TH:i:s.u'),
|
||||
]];
|
||||
|
||||
$this->synchronizer->sync(false, 0, 100);
|
||||
|
||||
$state = $this->state();
|
||||
$this->assertSame(0, $db->table('third_party_warehouse_inout_headers')
|
||||
->where('generation', $state->active_header_generation)->where('id', 50050)->count());
|
||||
$this->assertSame(0, $db->table('third_party_warehouse_inout_items')
|
||||
->where('generation', $state->active_item_generation)->where('io_id', 50050)->count());
|
||||
$this->assertSame(1, $db->table('third_party_warehouse_inout_headers')
|
||||
->where('generation', 'header-old')->where('id', 50050)->count());
|
||||
}
|
||||
|
||||
public function test_full_sync_catches_up_changes_through_the_inventory_capture_boundary(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-09-01 03:20:00');
|
||||
$this->upstream->headerHighWatermark = 10;
|
||||
$this->upstream->headerResolver = static fn (): array => [[
|
||||
'source_id' => 10,
|
||||
'io_id' => 10,
|
||||
'wms_co_id' => 15045364,
|
||||
'status' => 'Confirmed',
|
||||
'io_type' => '采购进仓',
|
||||
'occurred_at' => '2026-08-01T10:00:00.000',
|
||||
'modified_at' => '2026-08-01T10:01:00.000',
|
||||
]];
|
||||
$this->upstream->itemResolver = static fn (array $ioIds): array => [[
|
||||
'source_id' => $ioIds === [10] ? 10 : 20,
|
||||
'io_id' => $ioIds[0],
|
||||
'sku_code' => $ioIds === [10] ? 'OLD-SKU' : 'NEW-SKU',
|
||||
'qty' => 1,
|
||||
'qty_type' => 1,
|
||||
]];
|
||||
$this->upstream->inventoryHighWatermark = 1;
|
||||
$this->upstream->inventoryResolver = static function (): array {
|
||||
Carbon::setTestNow('2026-09-04 03:20:00');
|
||||
|
||||
return [[
|
||||
'source_id' => 1,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'NEW-SKU',
|
||||
'qty' => 1,
|
||||
'source_modified_at' => '2026-09-04T03:19:00.000',
|
||||
]];
|
||||
};
|
||||
$this->upstream->modifiedHeaderResolver = static function (): array {
|
||||
Carbon::setTestNow('2026-09-05 03:20:00');
|
||||
|
||||
return [[
|
||||
'source_id' => 20,
|
||||
'io_id' => 20,
|
||||
'wms_co_id' => 15045364,
|
||||
'status' => 'Confirmed',
|
||||
'io_type' => '采购进仓',
|
||||
'occurred_at' => '2026-09-03T10:00:00.000',
|
||||
'modified_at' => '2026-09-03T10:01:00.000',
|
||||
]];
|
||||
};
|
||||
|
||||
$result = $this->synchronizer->sync(true, 0, 100);
|
||||
|
||||
$this->assertSame('succeeded', $result['status']);
|
||||
$this->assertSame([
|
||||
['2026-08-30 03:20:00.000', 0, '2026-09-04 03:20:00.000', 2],
|
||||
], $this->upstream->modifiedHeaderCalls);
|
||||
$this->assertSame([10, 20], DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_inout_headers')->orderBy('id')->pluck('id')->all());
|
||||
$this->assertSame('2026-09-04 03:20:00.000', $this->state()->header_modified_cursor_at);
|
||||
$this->assertSame('2026-09-05 03:20:00', $this->state()->last_succeeded_at);
|
||||
|
||||
$this->upstream->modifiedHeaderCalls = [];
|
||||
$this->upstream->modifiedHeaderResolver = static fn (): array => [];
|
||||
$this->synchronizer->sync(false, 0, 100);
|
||||
|
||||
$this->assertSame('2026-09-02 03:20:00.000', $this->upstream->modifiedHeaderCalls[0][0]);
|
||||
}
|
||||
|
||||
public function test_publish_retains_previous_generation_and_cleans_only_expired_releases(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-09-11 03:20:00');
|
||||
$this->seedReadyMirror();
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->table('third_party_warehouse_inout_headers')->insert([
|
||||
'generation' => 'header-expired', 'id' => 99, 'io_id' => 99, 'wms_co_id' => 15045364,
|
||||
'row_hash' => str_repeat('d', 64), 'synced_at' => now(),
|
||||
]);
|
||||
$db->table('third_party_warehouse_inout_items')->insert([
|
||||
'generation' => 'item-expired', 'id' => 99, 'io_id' => 99, 'sku_code' => 'EXPIRED',
|
||||
'qty' => 1, 'qty_type' => 1, 'row_hash' => str_repeat('e', 64), 'synced_at' => now(),
|
||||
]);
|
||||
$db->table('third_party_warehouse_inventory_snapshots')->insert([
|
||||
'generation' => 'inventory-expired', 'id' => 99, 'wms_co_id' => 15045364,
|
||||
'sku_code' => 'EXPIRED', 'qty' => 1, 'captured_at' => now(),
|
||||
'row_hash' => str_repeat('f', 64), 'synced_at' => now(),
|
||||
]);
|
||||
$db->table('third_party_warehouse_generation_releases')->insert([
|
||||
'release_id' => 'release-expired',
|
||||
'dataset_key' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
|
||||
'header_generation' => 'header-expired',
|
||||
'item_generation' => 'item-expired',
|
||||
'inventory_generation' => 'inventory-expired',
|
||||
'status' => 'retired',
|
||||
'published_at' => '2026-09-01 03:20:00',
|
||||
'retired_at' => '2026-09-08 03:19:59',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$this->upstream->inventoryHighWatermark = 1;
|
||||
$this->upstream->inventoryResolver = static fn (): array => [[
|
||||
'source_id' => 1,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'NEW-1',
|
||||
'qty' => 2,
|
||||
'source_modified_at' => '2026-09-11T03:19:00.000',
|
||||
]];
|
||||
|
||||
$result = $this->synchronizer->sync(false, 0, 100);
|
||||
$state = $this->state();
|
||||
|
||||
$this->assertSame('succeeded', $result['status']);
|
||||
$this->assertSame(1, $db->table('third_party_warehouse_inout_headers')
|
||||
->where('generation', 'header-old')->count());
|
||||
$this->assertSame(1, $db->table('third_party_warehouse_inout_items')
|
||||
->where('generation', 'item-old')->count());
|
||||
$this->assertSame(1, $db->table('third_party_warehouse_inventory_snapshots')
|
||||
->where('generation', 'inventory-old')->count());
|
||||
$this->assertSame('retired', $db->table('third_party_warehouse_generation_releases')
|
||||
->where('header_generation', 'header-old')->value('status'));
|
||||
$this->assertSame('active', $db->table('third_party_warehouse_generation_releases')
|
||||
->where('header_generation', $state->active_header_generation)->value('status'));
|
||||
$this->assertSame(0, $db->table('third_party_warehouse_inout_headers')
|
||||
->where('generation', 'header-expired')->count());
|
||||
$this->assertSame(0, $db->table('third_party_warehouse_generation_releases')
|
||||
->where('release_id', 'release-expired')->count());
|
||||
}
|
||||
|
||||
public function test_superseded_worker_cannot_write_rows_or_switch_the_active_generation(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-09-11 03:20:00');
|
||||
$this->seedReadyMirror();
|
||||
$this->upstream->inventoryHighWatermark = 1;
|
||||
$this->upstream->inventoryResolver = static fn (): array => [[
|
||||
'source_id' => 1,
|
||||
'wms_co_id' => 15045364,
|
||||
'sku_code' => 'NEW-1',
|
||||
'qty' => 2,
|
||||
'source_modified_at' => '2026-09-11T03:19:00.000',
|
||||
]];
|
||||
$this->upstream->modifiedHeaderResolver = static function (): array {
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
|
||||
->where('dataset_key', ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY)
|
||||
->update(['run_token' => 'replacement-run-token']);
|
||||
|
||||
return [[
|
||||
'source_id' => 700,
|
||||
'io_id' => 700,
|
||||
'wms_co_id' => 15045364,
|
||||
'status' => 'Confirmed',
|
||||
'io_type' => '采购进仓',
|
||||
'occurred_at' => '2026-09-10T10:00:00.000',
|
||||
'modified_at' => '2026-09-10T10:01:00.000',
|
||||
]];
|
||||
};
|
||||
|
||||
$result = $this->synchronizer->sync(false, 0, 100);
|
||||
$state = $this->state();
|
||||
|
||||
$this->assertSame('already_running', $result['status']);
|
||||
$this->assertSame('superseded', $result['phase']);
|
||||
$this->assertSame('replacement-run-token', $state->run_token);
|
||||
$this->assertSame('header-old', $state->active_header_generation);
|
||||
$this->assertSame('inventory-old', $state->active_inventory_generation);
|
||||
$this->assertSame(0, DB::connection('business_mirror')
|
||||
->table('third_party_warehouse_inout_headers')
|
||||
->where('generation', $state->pending_header_generation)
|
||||
->where('id', 700)
|
||||
->count());
|
||||
}
|
||||
|
||||
private function createMirrorSchema(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status')->default('pending');
|
||||
$table->string('run_mode')->nullable();
|
||||
$table->string('sync_phase')->nullable();
|
||||
$table->string('run_token')->nullable();
|
||||
$table->string('active_header_generation')->nullable();
|
||||
$table->string('active_item_generation')->nullable();
|
||||
$table->string('active_inventory_generation')->nullable();
|
||||
$table->string('pending_header_generation')->nullable();
|
||||
$table->string('pending_item_generation')->nullable();
|
||||
$table->string('pending_inventory_generation')->nullable();
|
||||
$table->unsignedBigInteger('header_cursor')->default(0);
|
||||
$table->unsignedBigInteger('header_high_watermark')->nullable();
|
||||
$table->dateTime('header_window_started_at')->nullable();
|
||||
$table->dateTime('header_window_ends_at')->nullable();
|
||||
$table->dateTime('header_modified_cursor_at')->nullable();
|
||||
$table->unsignedBigInteger('header_modified_cursor_id')->default(0);
|
||||
$table->unsignedBigInteger('inventory_cursor')->default(0);
|
||||
$table->unsignedBigInteger('inventory_high_watermark')->nullable();
|
||||
$table->unsignedBigInteger('header_row_count')->default(0);
|
||||
$table->unsignedBigInteger('item_row_count')->default(0);
|
||||
$table->unsignedBigInteger('inventory_row_count')->default(0);
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->dateTime('last_attempted_at')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->dateTime('last_full_succeeded_at')->nullable();
|
||||
$table->string('last_error_code')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
$schema->create('third_party_warehouse_generation_releases', function (Blueprint $table): void {
|
||||
$table->string('release_id')->primary();
|
||||
$table->string('dataset_key');
|
||||
$table->string('header_generation');
|
||||
$table->string('item_generation');
|
||||
$table->string('inventory_generation');
|
||||
$table->string('status');
|
||||
$table->dateTime('published_at');
|
||||
$table->dateTime('retired_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['dataset_key', 'header_generation', 'item_generation', 'inventory_generation']);
|
||||
});
|
||||
$schema->create('third_party_warehouse_inout_headers', function (Blueprint $table): void {
|
||||
$table->id('_mirror_id');
|
||||
$table->string('generation');
|
||||
$table->string('source_key')->default('jiansun');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->string('status')->nullable();
|
||||
$table->string('io_type')->nullable();
|
||||
$table->dateTime('occurred_at')->nullable();
|
||||
$table->dateTime('source_modified_at')->nullable();
|
||||
$table->string('row_hash');
|
||||
$table->dateTime('synced_at');
|
||||
$table->unique(['generation', 'id']);
|
||||
});
|
||||
$schema->create('third_party_warehouse_inout_items', function (Blueprint $table): void {
|
||||
$table->id('_mirror_id');
|
||||
$table->string('generation');
|
||||
$table->string('source_key')->default('jiansun');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->string('sku_code', 191);
|
||||
$table->decimal('qty', 20, 4)->default(0);
|
||||
$table->smallInteger('qty_type');
|
||||
$table->dateTime('source_modified_at')->nullable();
|
||||
$table->string('row_hash');
|
||||
$table->dateTime('synced_at');
|
||||
$table->unique(['generation', 'id']);
|
||||
});
|
||||
$schema->create('third_party_warehouse_inventory_snapshots', function (Blueprint $table): void {
|
||||
$table->id('_mirror_id');
|
||||
$table->string('generation');
|
||||
$table->string('source_key')->default('jiansun');
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->string('sku_code', 191);
|
||||
$table->decimal('qty', 20, 4)->default(0);
|
||||
$table->dateTime('source_modified_at')->nullable();
|
||||
$table->dateTime('captured_at')->nullable();
|
||||
$table->string('row_hash');
|
||||
$table->dateTime('synced_at');
|
||||
$table->unique(['generation', 'id']);
|
||||
$table->unique(['generation', 'wms_co_id', 'sku_code']);
|
||||
});
|
||||
}
|
||||
|
||||
private function seedReadyMirror(): void
|
||||
{
|
||||
$now = now();
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->insert([
|
||||
'dataset_key' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
|
||||
'status' => 'succeeded',
|
||||
'active_header_generation' => 'header-old',
|
||||
'active_item_generation' => 'item-old',
|
||||
'active_inventory_generation' => 'inventory-old',
|
||||
'header_cursor' => 100000,
|
||||
'header_row_count' => 1,
|
||||
'item_row_count' => 1,
|
||||
'inventory_row_count' => 1,
|
||||
'initial_completed_at' => $now,
|
||||
'last_succeeded_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
|
||||
'generation' => 'header-old', 'id' => 1, 'io_id' => 11, 'wms_co_id' => 15045364,
|
||||
'status' => 'Confirmed', 'io_type' => '采购进仓', 'row_hash' => str_repeat('a', 64),
|
||||
'synced_at' => $now,
|
||||
]);
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inout_items')->insert([
|
||||
'generation' => 'item-old', 'id' => 1, 'io_id' => 11, 'sku_code' => 'OLD-1',
|
||||
'qty' => 1, 'qty_type' => 1, 'row_hash' => str_repeat('b', 64), 'synced_at' => $now,
|
||||
]);
|
||||
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert([
|
||||
'generation' => 'inventory-old', 'id' => 1, 'wms_co_id' => 15045364,
|
||||
'sku_code' => 'OLD-1', 'qty' => 1, 'row_hash' => str_repeat('c', 64),
|
||||
'captured_at' => $now,
|
||||
'synced_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function state(): object
|
||||
{
|
||||
return DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
|
||||
->where('dataset_key', ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY)->first();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeThirdPartyWarehouseUpstreamRepository extends ThirdPartyWarehouseStorageUpstreamRepository
|
||||
{
|
||||
public int $headerHighWatermark = 0;
|
||||
|
||||
public int $inventoryHighWatermark = 0;
|
||||
|
||||
public ?Closure $headerResolver = null;
|
||||
|
||||
public ?Closure $modifiedHeaderResolver = null;
|
||||
|
||||
public ?Closure $latestHeaderResolver = null;
|
||||
|
||||
public ?Closure $itemResolver = null;
|
||||
|
||||
public ?Closure $inventoryResolver = null;
|
||||
|
||||
public ?RuntimeException $inventoryFailure = null;
|
||||
|
||||
/** @var list<array{int,int}> */
|
||||
public array $headerCalls = [];
|
||||
|
||||
/** @var list<array{string,int,string,int}> */
|
||||
public array $modifiedHeaderCalls = [];
|
||||
|
||||
/** @var list<array<string,mixed>> */
|
||||
public array $lastModifiedHeaders = [];
|
||||
|
||||
/** @var list<list<int|string>> */
|
||||
public array $latestHeaderCalls = [];
|
||||
|
||||
/** @var list<array{int,int,int}> */
|
||||
public array $inventoryCalls = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function headerHighWatermark(): int
|
||||
{
|
||||
return $this->headerHighWatermark;
|
||||
}
|
||||
|
||||
public function headerRange(int $afterId, int $throughId): array
|
||||
{
|
||||
$this->headerCalls[] = [$afterId, $throughId];
|
||||
|
||||
return $this->headerResolver === null
|
||||
? []
|
||||
: ($this->headerResolver)($afterId, $throughId);
|
||||
}
|
||||
|
||||
public function modifiedHeadersBatch(
|
||||
string $cursorAt,
|
||||
int $cursorId,
|
||||
string $throughAt,
|
||||
int $limit,
|
||||
): array {
|
||||
$this->modifiedHeaderCalls[] = [$cursorAt, $cursorId, $throughAt, $limit];
|
||||
|
||||
$rows = $this->modifiedHeaderResolver === null
|
||||
? []
|
||||
: ($this->modifiedHeaderResolver)($cursorAt, $cursorId, $throughAt, $limit);
|
||||
$this->lastModifiedHeaders = $rows;
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function latestHeadersForIoIds(array $ioIds, string $throughAt): array
|
||||
{
|
||||
$this->latestHeaderCalls[] = $ioIds;
|
||||
if ($this->latestHeaderResolver !== null) {
|
||||
return ($this->latestHeaderResolver)($ioIds);
|
||||
}
|
||||
|
||||
$byIoId = [];
|
||||
foreach ($this->lastModifiedHeaders as $row) {
|
||||
$ioId = (int) ($row['io_id'] ?? 0);
|
||||
if (! in_array($ioId, $ioIds, true)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($byIoId[$ioId])
|
||||
|| (int) ($row['source_id'] ?? 0) > (int) ($byIoId[$ioId]['source_id'] ?? 0)) {
|
||||
$byIoId[$ioId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($byIoId);
|
||||
}
|
||||
|
||||
public function itemsForIoIds(array $ioIds): array
|
||||
{
|
||||
return $this->itemResolver === null ? [] : ($this->itemResolver)($ioIds);
|
||||
}
|
||||
|
||||
public function inventoryHighWatermark(): int
|
||||
{
|
||||
return $this->inventoryHighWatermark;
|
||||
}
|
||||
|
||||
public function inventoryBatch(int $afterId, int $highWatermark, int $limit): array
|
||||
{
|
||||
$this->inventoryCalls[] = [$afterId, $highWatermark, $limit];
|
||||
if ($this->inventoryFailure !== null) {
|
||||
throw $this->inventoryFailure;
|
||||
}
|
||||
|
||||
return $this->inventoryResolver === null
|
||||
? []
|
||||
: ($this->inventoryResolver)($afterId, $highWatermark, $limit);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageUpstreamRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
{
|
||||
public function test_header_queries_use_the_fixed_source_table_warehouse_and_id_window(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$query->scalarResult = 987654;
|
||||
$repository = $this->repository($query);
|
||||
|
||||
$this->assertSame(987654, $repository->headerHighWatermark());
|
||||
$repository->headerRange(100, 200);
|
||||
|
||||
$this->assertStringContainsString('MAX([Id])', $query->scalarCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[dbo].[jst_inoutWater]', $query->scalarCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
|
||||
$this->assertSame([15045364], $query->scalarCalls[0]['bindings']);
|
||||
$this->assertStringContainsString('[Id] > ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[Id] <= ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[io_date]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('AS occurred_at', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('NOT EXISTS', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('newer.[io_id] = h.[io_id]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('newer.[Id] > h.[Id]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('ORDER BY h.[Id]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('h.[io_date] >= ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertSame([100, 200, 15045364, '2026-08-01'], $query->selectCalls[0]['bindings']);
|
||||
}
|
||||
|
||||
public function test_items_are_parameterized_and_split_into_bounded_io_id_batches(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$query->selectResults = [[
|
||||
['source_id' => 2, 'io_id' => 2],
|
||||
], [
|
||||
['source_id' => 1, 'io_id' => 501],
|
||||
]];
|
||||
$repository = $this->repository($query);
|
||||
|
||||
$rows = $repository->itemsForIoIds(range(1, 501));
|
||||
|
||||
$this->assertCount(2, $query->selectCalls);
|
||||
$this->assertCount(500, $query->selectCalls[0]['bindings']);
|
||||
$this->assertCount(1, $query->selectCalls[1]['bindings']);
|
||||
$this->assertStringContainsString('[dbo].[jst_inoutWaterItem]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[qty_type]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[io_id] IN (', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringNotContainsString('[io_id] IN (1,2', $query->selectCalls[0]['sql']);
|
||||
$this->assertSame([1, 2], array_column($rows, 'source_id'));
|
||||
}
|
||||
|
||||
public function test_incremental_headers_use_a_bounded_modified_id_cursor_without_a_warehouse_filter(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$repository = $this->repository($query);
|
||||
|
||||
$repository->modifiedHeadersBatch(
|
||||
'2026-09-09 03:20:00.000',
|
||||
123,
|
||||
'2026-09-11 03:20:00.000',
|
||||
99999,
|
||||
);
|
||||
|
||||
$call = $query->selectCalls[0];
|
||||
$this->assertStringContainsString('SELECT TOP (5000)', $call['sql']);
|
||||
$this->assertStringContainsString('[modified] >= ?', $call['sql']);
|
||||
$this->assertStringContainsString('[modified] < ?', $call['sql']);
|
||||
$this->assertStringContainsString('[modified] = ? AND [Id] > ?', $call['sql']);
|
||||
$this->assertStringContainsString('ORDER BY [modified], [Id]', $call['sql']);
|
||||
$this->assertStringNotContainsString('[wms_co_id] = ?', $call['sql']);
|
||||
$this->assertSame([
|
||||
'2026-09-09 03:20:00.000',
|
||||
'2026-09-11 03:20:00.000',
|
||||
'2026-09-09 03:20:00.000',
|
||||
'2026-09-09 03:20:00.000',
|
||||
123,
|
||||
], $call['bindings']);
|
||||
}
|
||||
|
||||
public function test_changed_documents_reload_only_the_latest_header_in_bounded_batches(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$query->selectResults = [[
|
||||
['source_id' => 2, 'io_id' => 2],
|
||||
], [
|
||||
['source_id' => 1, 'io_id' => 501],
|
||||
]];
|
||||
$repository = $this->repository($query);
|
||||
|
||||
$rows = $repository->latestHeadersForIoIds(range(1, 501), '2026-09-11 03:20:00.000');
|
||||
|
||||
$this->assertCount(2, $query->selectCalls);
|
||||
$this->assertCount(502, $query->selectCalls[0]['bindings']);
|
||||
$this->assertCount(3, $query->selectCalls[1]['bindings']);
|
||||
$this->assertStringContainsString('MAX([Id]) AS latest_id', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[io_id] IN (', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[modified] < ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('ON latest.[latest_id] = h.[Id] WHERE h.[io_date] >= ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringNotContainsString('[modified] < ? AND [io_date] >= ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertSame('2026-09-11 03:20:00.000', $query->selectCalls[0]['bindings'][500]);
|
||||
$this->assertSame('2026-08-01', $query->selectCalls[0]['bindings'][501]);
|
||||
$this->assertSame([1, 2], array_column($rows, 'source_id'));
|
||||
|
||||
$repository->latestHeadersForIoIds([], '2026-09-11 03:20:00.000');
|
||||
$this->assertCount(2, $query->selectCalls);
|
||||
}
|
||||
|
||||
public function test_inventory_uses_target_warehouse_keyset_pagination_and_a_bounded_limit(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$query->scalarResult = 191818;
|
||||
$repository = $this->repository($query);
|
||||
|
||||
$this->assertSame(191818, $repository->inventoryHighWatermark());
|
||||
$repository->inventoryBatch(12350, 191818, 99999);
|
||||
|
||||
$this->assertStringContainsString('[dbo].[jst_inventoryInfo]', $query->scalarCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
|
||||
$this->assertSame([15045364], $query->scalarCalls[0]['bindings']);
|
||||
$this->assertStringContainsString('SELECT TOP (5000)', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[dbo].[jst_inventoryInfo]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[Id] > ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[Id] <= ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('ORDER BY [Id]', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('AS source_modified_at', $query->selectCalls[0]['sql']);
|
||||
$this->assertSame([12350, 191818, 15045364], $query->selectCalls[0]['bindings']);
|
||||
|
||||
$repository->inventoryBatch(191818, 191818, 2000);
|
||||
$this->assertCount(1, $query->selectCalls);
|
||||
}
|
||||
|
||||
private function repository(UpstreamReadOnlyQuery $query): ThirdPartyWarehouseStorageUpstreamRepository
|
||||
{
|
||||
$factory = new class($query) extends UpstreamReadOnlyQueryFactory {
|
||||
public function __construct(private readonly UpstreamReadOnlyQuery $query)
|
||||
{
|
||||
}
|
||||
|
||||
public function forSource(string $source): UpstreamReadOnlyQuery
|
||||
{
|
||||
TestCase::assertSame('jiansun', $source);
|
||||
|
||||
return $this->query;
|
||||
}
|
||||
};
|
||||
|
||||
return new ThirdPartyWarehouseStorageUpstreamRepository($factory);
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingThirdPartyWarehouseQuery extends UpstreamReadOnlyQuery
|
||||
{
|
||||
public mixed $scalarResult = 0;
|
||||
|
||||
/** @var list<list<array<string,mixed>>> */
|
||||
public array $selectResults = [];
|
||||
|
||||
/** @var list<array{sql:string,bindings:array}> */
|
||||
public array $selectCalls = [];
|
||||
|
||||
/** @var list<array{sql:string,bindings:array}> */
|
||||
public array $scalarCalls = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('jiansun');
|
||||
}
|
||||
|
||||
public function select(string $sql, array $bindings = []): array
|
||||
{
|
||||
$this->selectCalls[] = compact('sql', 'bindings');
|
||||
|
||||
return array_shift($this->selectResults) ?? [];
|
||||
}
|
||||
|
||||
public function scalar(string $sql, array $bindings = []): mixed
|
||||
{
|
||||
$this->scalarCalls[] = compact('sql', 'bindings');
|
||||
|
||||
return $this->scalarResult;
|
||||
}
|
||||
}
|
||||
90
tests/Unit/View/ThirdPartyWarehouseStorageViewTest.php
Normal file
90
tests/Unit/View/ThirdPartyWarehouseStorageViewTest.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\View;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ThirdPartyWarehouseStorageViewTest extends TestCase
|
||||
{
|
||||
public function test_view_keeps_the_excel_columns_in_the_required_order(): void
|
||||
{
|
||||
$source = $this->viewSource();
|
||||
|
||||
preg_match_all(
|
||||
"/\\['key' => '([^']+)', 'label' => '([^']+)'(?:, 'type' => '[^']+')?\\],/",
|
||||
$source,
|
||||
$matches,
|
||||
PREG_SET_ORDER
|
||||
);
|
||||
|
||||
$columns = array_map(
|
||||
static fn (array $match): array => [$match[1], $match[2]],
|
||||
array_slice($matches, 0, 28)
|
||||
);
|
||||
|
||||
$this->assertSame([
|
||||
['owner', '货主'],
|
||||
['date', '日期'],
|
||||
['sku_code', '商品编码'],
|
||||
['image_url', '图片'],
|
||||
['product_name', '商品名称'],
|
||||
['short_name', '商品简称'],
|
||||
['supplier', '供应商'],
|
||||
['category', '分类'],
|
||||
['virtual_category', '虚拟分类'],
|
||||
['supplier_style_no', '供应商款号'],
|
||||
['color_spec', '颜色规格'],
|
||||
['brand', '品牌'],
|
||||
['unit', '单位'],
|
||||
['opening_qty', '期初数量'],
|
||||
['closing_qty', '期末数量'],
|
||||
['processing_in', '加工进仓'],
|
||||
['processing_out', '加工出仓'],
|
||||
['purchase_in', '采购进仓'],
|
||||
['purchase_return', '采购退货'],
|
||||
['other_in', '其它进仓'],
|
||||
['other_return', '其它退货'],
|
||||
['sales_out', '销售出仓'],
|
||||
['sales_return', '销售退货'],
|
||||
['stocktake', '盘点'],
|
||||
['transfer_out', '调拨出'],
|
||||
['transfer_in', '调拨入'],
|
||||
['opening_aux', '期初'],
|
||||
['other_out', '其它出库'],
|
||||
], $columns);
|
||||
}
|
||||
|
||||
public function test_view_reuses_finance_table_preferences_and_has_explicit_sync_states(): void
|
||||
{
|
||||
$source = $this->viewSource();
|
||||
|
||||
$this->assertStringContainsString('data-finance-table-surface', $source);
|
||||
$this->assertStringContainsString('data-finance-field-picker', $source);
|
||||
$this->assertStringContainsString('data-finance-table', $source);
|
||||
$this->assertStringContainsString('data-finance-export-base', $source);
|
||||
$this->assertStringContainsString('data-finance-export-query', $source);
|
||||
$this->assertStringContainsString('name="dimension" value="month"', $source);
|
||||
$this->assertStringContainsString('name="dimension" value="day"', $source);
|
||||
$this->assertStringContainsString('name="period"', $source);
|
||||
$this->assertStringContainsString("\$rows->appends(\$activeFilters)", $source);
|
||||
$this->assertStringContainsString('data-warehouse-period', $source);
|
||||
$this->assertStringContainsString('查询维度:商品 + 按天', $source);
|
||||
$this->assertStringContainsString('每日 00:00 自动同步', $source);
|
||||
$this->assertStringContainsString('首次同步尚未完成', $source);
|
||||
$this->assertStringContainsString('当前展示最近一次成功同步的数据', $source);
|
||||
$this->assertStringContainsString("in_array(\$scheme, ['http', 'https'], true)", $source);
|
||||
$this->assertStringContainsString('referrerpolicy="no-referrer"', $source);
|
||||
}
|
||||
|
||||
private function viewSource(): string
|
||||
{
|
||||
$root = dirname(__DIR__, 3);
|
||||
$source = file_get_contents(
|
||||
$root . '/resources/views/business/pages/courier-payables/third-party-warehouse-storage.blade.php'
|
||||
);
|
||||
|
||||
$this->assertIsString($source);
|
||||
|
||||
return $source;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user