Compare commits
2 Commits
f4d8a15aed
...
1f96ef3812
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f96ef3812 | |||
| 1e3f014743 |
@ -10,10 +10,10 @@ use Throwable;
|
||||
class SyncThirdPartyWarehouseStorage extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-third-party-warehouse-storage
|
||||
{--full : 启动或继续三方仓流水与库存的全量校准}
|
||||
{--full : 启动或继续聚水潭全仓流水与库存的全量校准}
|
||||
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
|
||||
|
||||
protected $description = '同步南昌樵舍三方仓按商品进销存数据到共享业务镜像';
|
||||
protected $description = '同步聚水潭全部仓库按商品进销存数据到共享业务镜像';
|
||||
|
||||
public function handle(ThirdPartyWarehouseStorageSynchronizer $synchronizer): int
|
||||
{
|
||||
@ -27,29 +27,29 @@ class SyncThirdPartyWarehouseStorage extends Command
|
||||
$this->option('max-batches') === null ? null : (int) $this->option('max-batches'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('三方仓仓储数据共享镜像同步失败。', [
|
||||
Log::error('聚水潭进销存数据共享镜像同步失败。', [
|
||||
'dataset' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
$this->error('三方仓仓储数据同步失败,页面继续读取上一版完整数据。');
|
||||
$this->error('聚水潭进销存数据同步失败,页面继续读取上一版完整数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'requires_full_sync') {
|
||||
$this->warn('三方仓仓储数据尚未完成首次全量同步,请显式使用 --full。');
|
||||
$this->warn('聚水潭进销存数据尚未完成首次全量同步,请显式使用 --full。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'already_running') {
|
||||
$this->line('三方仓仓储数据已有同步任务运行,本次不重复启动。');
|
||||
$this->line('聚水潭进销存数据已有同步任务运行,本次不重复启动。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'三方仓仓储数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。',
|
||||
'聚水潭进销存数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。',
|
||||
$result['mode'] === 'full' ? '全量校准' : '增量同步',
|
||||
$result['status'] === 'succeeded' ? '完成' : '进行中',
|
||||
$result['batches'],
|
||||
|
||||
@ -382,7 +382,7 @@ class Pages extends Controller
|
||||
$upstreamError = null;
|
||||
|
||||
if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') {
|
||||
$rows = $this->inventoryReportDemoRows();
|
||||
[$rows, $upstreamError] = $this->inventoryReportRows();
|
||||
}
|
||||
|
||||
if ($page && $content === null) {
|
||||
@ -393,7 +393,7 @@ class Pages extends Controller
|
||||
);
|
||||
|
||||
if ($descriptor) {
|
||||
if ($descriptor->readOnly) {
|
||||
if ($descriptor->readOnly) {
|
||||
try {
|
||||
$rows = app(UpstreamBusinessInformation::class)->paginate(
|
||||
$section['slug'],
|
||||
@ -432,7 +432,11 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) {
|
||||
if (($section['slug'] ?? null) === 'product-inventory'
|
||||
&& ($page['slug'] ?? null) === 'inventory-report') {
|
||||
// inventoryReportRows() 已从聚水潭共享镜像按全仓口径取数。
|
||||
// 这里必须保留该结果,不能再被目录表的旧演示数据覆盖。
|
||||
} elseif ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) {
|
||||
try {
|
||||
app(\App\Business\StoreReceivingAccountSynchronizer::class)->sync((int) (company_id() ?: 1));
|
||||
} catch (Throwable $exception) {
|
||||
@ -485,24 +489,60 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
private function inventoryReportDemoRows(): LengthAwarePaginator
|
||||
/** @return array{0:LengthAwarePaginator,1:?string} */
|
||||
private function inventoryReportRows(): array
|
||||
{
|
||||
$columns = config('inventory_report_demo.columns', []);
|
||||
$rows = collect(config('inventory_report_demo.rows', []))
|
||||
->map(fn (array $row): array => array_combine($columns, $row));
|
||||
$perPage = 50;
|
||||
$currentPage = max(1, request()->integer('page', 1));
|
||||
$page = max(1, request()->integer('page', 1));
|
||||
$perPage = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$month = trim((string) request('month', now()->startOfMonth()->subMonth()->format('Y-m')));
|
||||
if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1
|
||||
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
|
||||
$month = now()->startOfMonth()->subMonth()->format('Y-m');
|
||||
}
|
||||
$minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7);
|
||||
if ($month < $minimumMonth) {
|
||||
$month = $minimumMonth;
|
||||
}
|
||||
$filters = [
|
||||
'month' => $month,
|
||||
'product_code' => mb_substr(trim((string) request('product_code', '')), 0, 100),
|
||||
'warehouse_provider' => mb_substr(trim((string) request('warehouse_provider', '')), 0, 191),
|
||||
'category' => mb_substr(trim((string) request('category', '')), 0, 191),
|
||||
];
|
||||
$error = null;
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$rows->forPage($currentPage, $perPage)->values(),
|
||||
$rows->count(),
|
||||
$perPage,
|
||||
$currentPage,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'query' => request()->query(),
|
||||
],
|
||||
);
|
||||
try {
|
||||
if (! $this->thirdPartyWarehouseStorage->available()) {
|
||||
$error = '进销存共享镜像尚未建立,暂时无法读取。';
|
||||
} elseif (! $this->thirdPartyWarehouseStorage->allWarehousesReady()) {
|
||||
$error = '进销存全仓库共享镜像正在执行首次同步,完成前不会展示不完整的单仓数据。';
|
||||
} else {
|
||||
$result = $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport(
|
||||
$filters,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
|
||||
return [new LengthAwarePaginator(
|
||||
$result['data'],
|
||||
$result['total'],
|
||||
$perPage,
|
||||
$page,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'query' => request()->except('page'),
|
||||
],
|
||||
), null];
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '进销存共享镜像暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return [new LengthAwarePaginator([], 0, $perPage, $page, [
|
||||
'path' => request()->url(),
|
||||
'query' => request()->except('page'),
|
||||
]), $error];
|
||||
}
|
||||
|
||||
private function isStoreReceivingAccountPage(string $section, string $page): bool
|
||||
|
||||
@ -307,9 +307,9 @@ $navigation = [
|
||||
'title' => '进销存报表',
|
||||
'directory' => [
|
||||
'icon' => 'inventory',
|
||||
'status' => '演示数据',
|
||||
'description' => '按进销存报表模板展示货主、仓库、商品及库存变动数据。',
|
||||
'empty' => '暂无进销存演示数据。',
|
||||
'status' => '共享镜像',
|
||||
'description' => '复用“应付款项 → 快递应付 → 三方仓进销存”的已确认流水、库存锚点和业务类型口径,按所有仓库、商品和月份汇总。',
|
||||
'empty' => '进销存共享镜像尚未完成首次同步。',
|
||||
'fields' => [
|
||||
['name' => 'owner', 'label' => '货主'],
|
||||
['name' => 'month', 'label' => '月份', 'kind' => 'date'],
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'columns' => [
|
||||
'owner', 'month', 'warehouse_provider', 'warehouse', 'product_code', 'category',
|
||||
'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out',
|
||||
'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out',
|
||||
'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out',
|
||||
],
|
||||
'rows' => [
|
||||
['常州简尚家居有限公司', '2026-09-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓主仓', '100006981661', '住宅家具 - 书桌', 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '简尚-横林二仓', '客退临时上架仓', '110886Z15', null, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '121630-49', '其他 - 配件', -2, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓主仓', '121640', '住宅家具 - 门厅/玄关柜/玄关架', 12, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135150-13', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '简尚-分销部专用仓', '简尚-分销部专用仓主仓', '135580', '住宅家具 - 餐边柜', 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-1', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-12', '其他 - 配件', -3, -3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-13', '其他 - 配件', -3, -4, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-14', '其他 - 配件', -4, -5, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-15', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-16', '其他 - 配件', -1, -3, 0, 0, 0, 0, 0, 0, -2, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-17', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-18', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-19', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-21', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-26', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-28', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '常州简尚家居有限公司', '配件单仓', '135590-33', '其他 - 配件', -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['常州简尚家居有限公司', '2026-09-01', '简尚-分销部崔桥专用仓', '简尚-分销部崔桥专用仓主仓', '135600', '住宅家具 - 餐边柜', 10, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
];
|
||||
@ -87,8 +87,8 @@ return [
|
||||
'overlap_rows' => (int) env('STORE_REPORT_MIRROR_OVERLAP_ROWS', 50000),
|
||||
],
|
||||
|
||||
// 聚水潭进销存报表:固定只读“三方-南昌樵舍仓”,页面只读共享镜像。
|
||||
// 头表按 Id 区间小批读取并固定仓库,避免对上游流水表做无界扫描。
|
||||
// 聚水潭进销存共享流水镜像:头表按 Id 区间小批读取全部仓库。
|
||||
// 三方仓页面在 Repository 层继续固定南昌樵舍仓,进销存报表按全仓库汇总。
|
||||
'third_party_warehouse_storage' => [
|
||||
'dataset_key' => 'jiansun.third-party-warehouse-storage',
|
||||
'source_key' => 'jiansun',
|
||||
@ -222,6 +222,7 @@ return [
|
||||
['sku_type', 'autoid'],
|
||||
['sku_type', 'i_id', 'autoid'],
|
||||
['sku_type', 'sku_id', 'autoid'],
|
||||
['sku_id', 'autoid'],
|
||||
['sku_type', 'enabled', 'autoid'],
|
||||
['sku_type', 'stock_disabled', 'autoid'],
|
||||
],
|
||||
|
||||
@ -18,6 +18,7 @@ return new class extends Migration
|
||||
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->unsignedTinyInteger('scope_version')->default(1);
|
||||
$table->string('status', 32)->default('pending');
|
||||
$table->string('run_mode', 24)->nullable();
|
||||
$table->string('sync_phase', 24)->nullable();
|
||||
@ -78,6 +79,7 @@ return new class extends Migration
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedBigInteger('wh_id')->default(0);
|
||||
$table->string('status', 32)->nullable();
|
||||
$table->string('io_type', 64)->nullable();
|
||||
$table->dateTime('occurred_at', 3)->nullable();
|
||||
@ -90,6 +92,10 @@ return new class extends Migration
|
||||
['generation', 'status', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_report_index',
|
||||
);
|
||||
$table->index(
|
||||
['generation', 'wms_co_id', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_warehouse_report_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
<?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_inout_headers')
|
||||
&& ! $schema->hasColumn('third_party_warehouse_inout_headers', 'wh_id')) {
|
||||
$schema->table('third_party_warehouse_inout_headers', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('wh_id')->default(0)->after('wms_co_id');
|
||||
$table->index(
|
||||
['generation', 'wms_co_id', 'occurred_at', 'io_type'],
|
||||
'tpw_headers_warehouse_report_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if ($schema->hasTable('third_party_warehouse_inout_headers')
|
||||
&& $schema->hasColumn('third_party_warehouse_inout_headers', 'wh_id')) {
|
||||
$schema->table('third_party_warehouse_inout_headers', function (Blueprint $table): void {
|
||||
$table->dropIndex('tpw_headers_warehouse_report_index');
|
||||
$table->dropColumn('wh_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<?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', 'scope_version')) {
|
||||
$schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->unsignedTinyInteger('scope_version')->default(1)->after('dataset_key');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if ($schema->hasTable('third_party_warehouse_sync_states')
|
||||
&& $schema->hasColumn('third_party_warehouse_sync_states', 'scope_version')) {
|
||||
$schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->dropColumn('scope_version');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -12,7 +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 起的数据;按月或单日选择后均输出“日期 + 商品”明细,并支持分页、筛选、数量汇总和导出。 |
|
||||
| 本次接入 | 聚水潭进销存共享镜像读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 的全部仓库,保留 2026-08-01 起的已确认流水与库存锚点。“快递应付 / 三方仓进销存”在查询层继续固定 `wms_co_id = 15045364`;“商品价格管理 / 进销存报表”按所有仓库、商品和月份汇总。 |
|
||||
| 已接入校准 | 聚水潭“普通商品资料”完整 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` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 |
|
||||
@ -64,7 +64,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.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(全部仓库、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像。三方仓页面仍固定 `wms_co_id = 15045364`,商品价格管理的进销存报表读取全仓库) |
|
||||
| 安装明细 | `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` |
|
||||
@ -98,7 +98,7 @@
|
||||
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. 得物货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.dewu_receiving` 数据集。同步器必须以参数绑定固定 `channel IN (Dewu, DewuTK, DewuKJ)`,页面 Repository 对列表、分页、筛选、汇总和导出使用相同渠道范围,并保留原始渠道代码用于审计;`Dewubzj` 保证金不得混入。
|
||||
13. 三方仓进销存首次全量按头表 `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` 执行增量同步,失败时继续展示上一成功代次。
|
||||
13. 聚水潭进销存首次全量按头表 `Id` 区间小批断点续传,头表保存全部仓库中 `io_date >= 2026-08-01` 的数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时三方仓页面继续读取上一成功代次,全仓报表在首份完整全仓代次发布前明确提示同步中。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个“仓库 + SKU”的抓取时间为回推锚点;三方仓页面仍固定 `wms_co_id = 15045364` 并按“日期 + 商品”输出,全仓报表按“月份 + 仓库 + 商品”汇总。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。
|
||||
14. 聚水潭商品操作日志按最多 7 天的固定时间窗和接口页码稳定断点同步,每页最多 50 条,首次全量从 `2000-01-01` 起扫描;只保留备注内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按接口记录字段生成稳定键幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。
|
||||
|
||||
工作平台采购价格首次全量需在备份和负责人授权后单独执行:
|
||||
|
||||
@ -42,10 +42,14 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
|
||||
private const PRODUCT_TABLE = 'ops_jst_sku';
|
||||
|
||||
private const WAREHOUSE_TABLE = 'ops_jst_warehouse_info';
|
||||
|
||||
private const GENERIC_STATE_TABLE = 'business_mirror_table_states';
|
||||
|
||||
private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku';
|
||||
|
||||
private const WAREHOUSE_DATASET_KEY = 'operations_replica.rpt.jst_warehouseInfo';
|
||||
|
||||
public const START_DATE = '2026-08-01';
|
||||
|
||||
/** @var array<string,string> */
|
||||
@ -123,6 +127,15 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
&& $this->dependenciesReady();
|
||||
}
|
||||
|
||||
public function allWarehousesReady(): bool
|
||||
{
|
||||
$state = $this->state();
|
||||
|
||||
return $this->ready()
|
||||
&& (int) ($state['scope_version'] ?? 1) >= 2
|
||||
&& $this->warehouseDependencyReady();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function state(): ?array
|
||||
{
|
||||
@ -218,6 +231,159 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品价格管理的进销存报表与三方仓共用同一套已确认流水、
|
||||
* 库存锚点和业务类型口径,只在这里将日明细再汇总为“商品 + 仓库 + 月”。
|
||||
*
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{data:list<object>,total:int,state:array<string,mixed>,sync:array<string,mixed>}
|
||||
*/
|
||||
public function paginateMonthlyInventoryReport(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$state = $this->requireReadyState();
|
||||
if ((int) ($state['scope_version'] ?? 1) < 2) {
|
||||
throw new RuntimeException('inventory_report_all_warehouses_not_ready');
|
||||
}
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$period = $this->period([
|
||||
'dimension' => 'month',
|
||||
'period' => $filters['month'] ?? '',
|
||||
]);
|
||||
$headerGeneration = $this->generation($state, 'active_header_generation');
|
||||
$itemGeneration = $this->generation($state, 'active_item_generation');
|
||||
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
|
||||
if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) {
|
||||
throw new RuntimeException('inventory_report_mirror_not_ready');
|
||||
}
|
||||
|
||||
$anchorAt = $this->stateAnchor($state);
|
||||
$rangeStart = min($period['start'], $anchorAt);
|
||||
$rangeEnd = max($period['end'], $anchorAt);
|
||||
$movementPairs = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
|
||||
->where('h.occurred_at', '>=', $rangeStart)
|
||||
->where('h.occurred_at', '<', $rangeEnd)
|
||||
->select(['h.wms_co_id', 'i.sku_code']);
|
||||
$inventoryPairs = $this->connection->table(self::INVENTORY_TABLE)
|
||||
->where('generation', $inventoryGeneration)
|
||||
->whereNotNull('sku_code')->where('sku_code', '!=', '')
|
||||
->select(['wms_co_id', 'sku_code'])
|
||||
->union($movementPairs);
|
||||
$anchors = $this->connection->query()->fromSub($inventoryPairs, 'pairs')
|
||||
->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void {
|
||||
$join->on('inventory.wms_co_id', '=', 'pairs.wms_co_id')
|
||||
->on('inventory.sku_code', '=', 'pairs.sku_code')
|
||||
->where('inventory.generation', '=', $inventoryGeneration);
|
||||
})
|
||||
->select(['pairs.wms_co_id', 'pairs.sku_code'])
|
||||
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
|
||||
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
|
||||
|
||||
$movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
|
||||
->where('h.occurred_at', '>=', $rangeStart)
|
||||
->where('h.occurred_at', '<', $rangeEnd)
|
||||
->select([
|
||||
'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code',
|
||||
'i.qty as movement_qty', 'i.qty_type as movement_qty_type',
|
||||
]);
|
||||
$signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))';
|
||||
$balanceAt = static fn (string $target): array => [
|
||||
'a.snapshot_qty + COALESCE(SUM(CASE '
|
||||
. 'WHEN a.snapshot_at < ? AND mv.occurred_at >= a.snapshot_at AND mv.occurred_at < ? THEN ' . $signed . ' '
|
||||
. 'WHEN a.snapshot_at >= ? AND mv.occurred_at >= ? AND mv.occurred_at < a.snapshot_at THEN -' . $signed . ' '
|
||||
. 'ELSE 0 END), 0)',
|
||||
[$target, $target, $target, $target],
|
||||
];
|
||||
[$openingSql, $openingBindings] = $balanceAt($period['start']);
|
||||
[$closingSql, $closingBindings] = $balanceAt($period['end']);
|
||||
$monthly = $this->connection->query()->fromSub($anchors, 'a')
|
||||
->leftJoinSub($movements, 'mv', function ($join): void {
|
||||
$join->on('mv.wms_co_id', '=', 'a.wms_co_id')
|
||||
->on('mv.sku_code', '=', 'a.sku_code');
|
||||
})
|
||||
->groupBy(['a.wms_co_id', 'a.sku_code', 'a.snapshot_qty', 'a.snapshot_at'])
|
||||
->select(['a.wms_co_id', 'a.sku_code'])
|
||||
->selectRaw($openingSql . ' AS opening_qty', $openingBindings)
|
||||
->selectRaw($closingSql . ' AS closing_qty', $closingBindings);
|
||||
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
|
||||
$monthly->selectRaw(
|
||||
'COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? '
|
||||
. 'AND mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0) AS ' . $column,
|
||||
[$period['start'], $period['end'], $type],
|
||||
);
|
||||
}
|
||||
$monthly->selectRaw('0 AS opening_aux');
|
||||
|
||||
$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');
|
||||
$latestWarehouses = $this->connection->table(self::WAREHOUSE_TABLE)
|
||||
->whereNotNull('wms_co_id')->groupBy('wms_co_id')
|
||||
->select('wms_co_id')->selectRaw('MAX(Id) AS Id');
|
||||
$query = $this->connection->query()->fromSub($monthly, 'm')
|
||||
->leftJoinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'm.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');
|
||||
})
|
||||
->leftJoinSub($latestWarehouses, 'latest_warehouse', 'latest_warehouse.wms_co_id', '=', 'm.wms_co_id')
|
||||
->leftJoin(self::WAREHOUSE_TABLE . ' as w', function ($join): void {
|
||||
$join->on('w.wms_co_id', '=', 'latest_warehouse.wms_co_id')
|
||||
->on('w.Id', '=', 'latest_warehouse.Id');
|
||||
})
|
||||
->select(['m.*', 's.category', 'w.name as warehouse_name']);
|
||||
$this->containsFilter($query, 'm.sku_code', $filters['product_code'] ?? '');
|
||||
$this->containsFilter($query, 's.category', $filters['category'] ?? '');
|
||||
$this->containsFilter($query, 'w.name', $filters['warehouse_provider'] ?? '');
|
||||
$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}('m.' . $column, '<>', 0);
|
||||
}
|
||||
});
|
||||
|
||||
$total = (clone $query)->count();
|
||||
$rows = $query->orderBy('w.name')->orderBy('m.sku_code')->forPage($page, $perPage)->get()
|
||||
->map(static function (object $row) use ($period): object {
|
||||
$warehouse = trim((string) ($row->warehouse_name ?? ''));
|
||||
|
||||
return (object) [
|
||||
'owner' => (string) config(
|
||||
'upstream_mirrors.third_party_warehouse_storage.owner_name',
|
||||
'常州简尚家居有限公司',
|
||||
),
|
||||
'month' => Carbon::parse($period['start'])->toDateString(),
|
||||
'warehouse_provider' => $warehouse,
|
||||
'warehouse' => $warehouse,
|
||||
'product_code' => (string) ($row->sku_code ?? ''),
|
||||
'category' => (string) ($row->category ?? ''),
|
||||
'opening_quantity' => $row->opening_qty,
|
||||
'closing_quantity' => $row->closing_qty,
|
||||
'processing_in' => $row->processing_in,
|
||||
'processing_out' => $row->processing_out,
|
||||
'purchase_in' => $row->purchase_in,
|
||||
'purchase_return' => $row->purchase_return,
|
||||
'other_in' => $row->other_in,
|
||||
'other_return' => $row->other_return,
|
||||
'sales_out' => $row->sales_out,
|
||||
'sales_return' => $row->sales_return,
|
||||
'stocktake' => $row->stocktake,
|
||||
'transfer_out' => $row->transfer_out,
|
||||
'transfer_in' => $row->transfer_in,
|
||||
'opening' => $row->opening_aux,
|
||||
'other_out' => $row->other_out,
|
||||
];
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{count:int,opening_qty:float,closing_qty:float}
|
||||
@ -536,15 +702,18 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
return $dates;
|
||||
}
|
||||
|
||||
private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder
|
||||
private function confirmedMovements(
|
||||
string $headerGeneration,
|
||||
string $itemGeneration,
|
||||
?int $warehouseId = self::WAREHOUSE_ID,
|
||||
): Builder
|
||||
{
|
||||
return $this->connection->table(self::ITEM_TABLE . ' as i')
|
||||
$query = $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 {
|
||||
@ -554,6 +723,12 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
->whereColumn('newer.id', '>', 'h.id');
|
||||
})
|
||||
->whereNotNull('i.sku_code')->where('i.sku_code', '!=', '');
|
||||
|
||||
if ($warehouseId !== null) {
|
||||
$query->where('h.wms_co_id', $warehouseId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -775,6 +950,23 @@ class ThirdPartyWarehouseStorageMirrorRepository
|
||||
}
|
||||
}
|
||||
|
||||
private function warehouseDependencyReady(): bool
|
||||
{
|
||||
try {
|
||||
if (! $this->connection->getSchemaBuilder()->hasTable(self::WAREHOUSE_TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->connection->table(self::GENERIC_STATE_TABLE)
|
||||
->where('dataset_key', self::WAREHOUSE_DATASET_KEY)
|
||||
->whereNotNull('initial_completed_at')
|
||||
->whereIn('status', ['succeeded', 'running', 'failed'])
|
||||
->exists();
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function generation(array $state, string $key): ?string
|
||||
{
|
||||
|
||||
@ -9,6 +9,7 @@ class ThirdPartyWarehouseStorageUpstreamRepository
|
||||
{
|
||||
public const SOURCE_KEY = 'jiansun';
|
||||
|
||||
/** 三方仓页面仍固定读取该仓;共享流水镜像覆盖全部仓库。 */
|
||||
public const WAREHOUSE_ID = 15045364;
|
||||
|
||||
public const START_DATE = '2026-08-01';
|
||||
@ -22,8 +23,7 @@ class ThirdPartyWarehouseStorageUpstreamRepository
|
||||
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],
|
||||
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater]',
|
||||
);
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ SELECT
|
||||
h.[Id] AS source_id,
|
||||
h.[io_id],
|
||||
h.[wms_co_id],
|
||||
h.[wh_id],
|
||||
h.[status],
|
||||
h.[type] AS io_type,
|
||||
CONVERT(varchar(27), h.[io_date], 126) AS occurred_at,
|
||||
@ -46,14 +47,13 @@ SELECT
|
||||
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]);
|
||||
SQL, [$afterId, $throughId, self::START_DATE]);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
@ -70,6 +70,7 @@ SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]);
|
||||
[Id] AS source_id,
|
||||
[io_id],
|
||||
[wms_co_id],
|
||||
[wh_id],
|
||||
[status],
|
||||
[type] AS io_type,
|
||||
CONVERT(varchar(27), [io_date], 126) AS occurred_at,
|
||||
@ -103,7 +104,7 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
|
||||
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], '
|
||||
'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[wh_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 '
|
||||
@ -165,8 +166,7 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
|
||||
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],
|
||||
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo]',
|
||||
);
|
||||
}
|
||||
|
||||
@ -189,9 +189,8 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
|
||||
FROM [dbo].[jst_inventoryInfo]
|
||||
WHERE [Id] > ?
|
||||
AND [Id] <= ?
|
||||
AND [wms_co_id] = ?
|
||||
ORDER BY [Id]
|
||||
SQL, [$afterId, $highWatermark, self::WAREHOUSE_ID]);
|
||||
SQL, [$afterId, $highWatermark]);
|
||||
}
|
||||
|
||||
private function query(): UpstreamReadOnlyQuery
|
||||
|
||||
@ -263,12 +263,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
$changedHeaders,
|
||||
))));
|
||||
$latestHeaders = $this->upstream->latestHeadersForIoIds($changedIoIds, $throughAt);
|
||||
$targetHeaders = array_values(array_filter(
|
||||
$latestHeaders,
|
||||
static fn (array $row): bool =>
|
||||
(int) ($row['wms_co_id'] ?? 0)
|
||||
=== ThirdPartyWarehouseStorageUpstreamRepository::WAREHOUSE_ID,
|
||||
));
|
||||
$targetHeaders = $latestHeaders;
|
||||
$items = $this->upstream->itemsForIoIds(array_map(
|
||||
static fn (array $row): mixed => $row['io_id'] ?? null,
|
||||
$targetHeaders,
|
||||
@ -395,7 +390,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
self::HEADER_TABLE,
|
||||
(string) ($state['active_header_generation'] ?? ''),
|
||||
(string) $values['pending_header_generation'],
|
||||
['source_key', 'id', 'io_id', 'wms_co_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'],
|
||||
['source_key', 'id', 'io_id', 'wms_co_id', 'wh_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'],
|
||||
);
|
||||
$this->cloneActiveGeneration(
|
||||
self::ITEM_TABLE,
|
||||
@ -692,6 +687,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
);
|
||||
$values['header_modified_cursor_id'] = 0;
|
||||
if ($mode === 'full') {
|
||||
$values['scope_version'] = 2;
|
||||
$values['initial_completed_at'] = $now;
|
||||
$values['last_full_succeeded_at'] = $now;
|
||||
}
|
||||
@ -1036,6 +1032,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
'id' => $id,
|
||||
'io_id' => $ioId,
|
||||
'wms_co_id' => $warehouseId,
|
||||
'wh_id' => max((int) ($row['wh_id'] ?? 0), 0),
|
||||
'status' => $this->text($row['status'] ?? null, 32),
|
||||
'io_type' => $this->text($row['io_type'] ?? null, 64),
|
||||
'occurred_at' => $this->dateTime($row['occurred_at'] ?? null),
|
||||
@ -1275,6 +1272,7 @@ class ThirdPartyWarehouseStorageSynchronizer
|
||||
'inventory_row_count',
|
||||
'initial_completed_at',
|
||||
'last_full_succeeded_at',
|
||||
'scope_version',
|
||||
] as $column) {
|
||||
if (! $schema->hasColumn(self::STATE_TABLE, $column)) {
|
||||
throw new RuntimeException('third_party_warehouse_mirror_state_missing:' . $column);
|
||||
|
||||
@ -452,6 +452,29 @@
|
||||
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
|
||||
</div>
|
||||
</form>
|
||||
@elseif (($page['slug'] ?? '') === 'inventory-report')
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="进销存报表筛选查询栏">
|
||||
<label class="installation-filter-field">
|
||||
<span>月份:</span>
|
||||
<input type="month" name="month" min="2026-08" value="{{ request('month', now()->startOfMonth()->subMonth()->format('Y-m')) }}" aria-label="进销存报表月份">
|
||||
</label>
|
||||
<label class="installation-filter-field">
|
||||
<span>商品编码:</span>
|
||||
<input type="text" name="product_code" value="{{ request('product_code') }}" placeholder="按商品编码精确查询">
|
||||
</label>
|
||||
<label class="installation-filter-field">
|
||||
<span>仓储方:</span>
|
||||
<input type="text" name="warehouse_provider" value="{{ request('warehouse_provider') }}" placeholder="支持包含查询">
|
||||
</label>
|
||||
<label class="installation-filter-field">
|
||||
<span>分类:</span>
|
||||
<input type="text" name="category" value="{{ request('category') }}" placeholder="支持包含查询">
|
||||
</label>
|
||||
<div class="installation-filter-actions">
|
||||
<button type="submit" class="biz-btn biz-btn-primary">查询</button>
|
||||
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
<div class="directory-table-tools" data-directory-tools>
|
||||
<label class="sr-only" for="directory-search-{{ $page['slug'] ?? 'page' }}">搜索当前表格</label>
|
||||
|
||||
@ -8,11 +8,30 @@ use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepo
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
||||
use Modules\EcommerceFinance\View\OrderDataPageData;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PagesTest extends FeatureTestCase
|
||||
{
|
||||
public function testInventoryReportDoesNotFallBackToLegacyRowsBeforeAllWarehouseMirrorIsReady(): void
|
||||
{
|
||||
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('allWarehousesReady')->once()->andReturnFalse();
|
||||
$repository->shouldNotReceive('paginateMonthlyInventoryReport');
|
||||
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'product-inventory',
|
||||
'page' => 'inventory-report',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('进销存全仓库共享镜像正在执行首次同步')
|
||||
->assertDontSeeText('已从共享业务镜像读取');
|
||||
}
|
||||
|
||||
public function testFreightReconciliationPageRendersSharedMirrorRowsAndSummary(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
|
||||
@ -37,11 +37,9 @@ class NavigationTest extends FeatureTestCase
|
||||
'采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货',
|
||||
'盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
])
|
||||
->assertSeeText('已按附件模板导入 20 条演示数据')
|
||||
->assertSeeText('常州简尚家居有限公司')
|
||||
->assertSeeText('100006981661')
|
||||
->assertSeeText('2026-09-01')
|
||||
->assertSeeText('住宅家具 - 门厅/玄关柜/玄关架')
|
||||
->assertSeeText('复用“应付款项 → 快递应付 → 三方仓进销存”')
|
||||
->assertSeeText('进销存共享镜像尚未建立')
|
||||
->assertDontSeeText('已按附件模板导入 20 条演示数据')
|
||||
->assertDontSeeText('进销存报表字段和数据来源尚未定义。');
|
||||
}
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
|
||||
$this->createMirrorTables();
|
||||
$this->createProductTable();
|
||||
$this->createWarehouseTable();
|
||||
$this->createDependencyStateTable();
|
||||
$this->repository = new ThirdPartyWarehouseStorageMirrorRepository(app('db'));
|
||||
}
|
||||
@ -139,6 +140,72 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame(15.0, $early['closing_qty']);
|
||||
}
|
||||
|
||||
public function test_inventory_report_aggregates_the_same_confirmed_movements_by_product_warehouse_and_month(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-MONTHLY', ['category' => '住宅家具 - 书桌']);
|
||||
$this->insertInventory('SKU-MONTHLY', 12, '2026-09-01 00:00:00');
|
||||
$this->insertMovement('SKU-MONTHLY', '采购进仓', 5, 1, '2026-08-02 10:00:00');
|
||||
$this->insertMovement('SKU-MONTHLY', '销售出仓', 3, -1, '2026-08-15 10:00:00');
|
||||
$this->insertMovement('SKU-MONTHLY', '销售退货', 2, 1, '2026-08-20 10:00:00');
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => 'SKU-MONTHLY',
|
||||
'warehouse_provider' => '南昌樵舍',
|
||||
'category' => '书桌',
|
||||
], 1, 25);
|
||||
$row = $result['data'][0];
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('2026-08-01', $row->month);
|
||||
$this->assertSame('三方-南昌樵舍仓', $row->warehouse_provider);
|
||||
$this->assertSame('三方-南昌樵舍仓', $row->warehouse);
|
||||
$this->assertSame('SKU-MONTHLY', $row->product_code);
|
||||
$this->assertSame('住宅家具 - 书桌', $row->category);
|
||||
$this->assertSame(8.0, (float) $row->opening_quantity);
|
||||
$this->assertSame(12.0, (float) $row->closing_quantity);
|
||||
$this->assertSame(5.0, (float) $row->purchase_in);
|
||||
$this->assertSame(-3.0, (float) $row->sales_out);
|
||||
$this->assertSame(2.0, (float) $row->sales_return);
|
||||
}
|
||||
|
||||
public function test_inventory_report_reads_and_filters_warehouses_outside_the_third_party_scope(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$this->insertProduct('SKU-ALL-WAREHOUSES', ['category' => '住宅家具 - 餐边柜']);
|
||||
$this->insertInventory('SKU-ALL-WAREHOUSES', 9, '2026-09-01 00:00:00', [
|
||||
'wms_co_id' => 15038426,
|
||||
]);
|
||||
$this->insertMovement('SKU-ALL-WAREHOUSES', '销售出仓', 20, -1, '2026-08-15 10:00:00', [
|
||||
'warehouse_id' => 15038426,
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginateMonthlyInventoryReport([
|
||||
'month' => '2026-08',
|
||||
'product_code' => 'SKU-ALL-WAREHOUSES',
|
||||
'warehouse_provider' => '崔桥',
|
||||
'category' => '餐边柜',
|
||||
], 1, 25);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('简尚-崔桥工厂仓', $result['data'][0]->warehouse_provider);
|
||||
$this->assertSame('SKU-ALL-WAREHOUSES', $result['data'][0]->product_code);
|
||||
$this->assertSame(-20.0, (float) $result['data'][0]->sales_out);
|
||||
}
|
||||
|
||||
public function test_third_party_page_remains_ready_when_only_the_all_warehouse_dimension_is_missing(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')
|
||||
->where('dataset_key', 'operations_replica.rpt.jst_warehouseInfo')
|
||||
->delete();
|
||||
Schema::connection('business_mirror')->drop('ops_jst_warehouse_info');
|
||||
|
||||
$this->assertTrue($this->repository->ready());
|
||||
$this->assertFalse($this->repository->allWarehousesReady());
|
||||
}
|
||||
|
||||
public function test_periods_before_the_supported_start_date_are_clamped(): void
|
||||
{
|
||||
$this->markReady();
|
||||
@ -411,6 +478,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->unsignedTinyInteger('scope_version')->default(1);
|
||||
$table->string('status')->nullable();
|
||||
$table->string('run_mode')->nullable();
|
||||
$table->string('active_header_generation')->nullable();
|
||||
@ -426,6 +494,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedBigInteger('wh_id')->default(0);
|
||||
$table->string('status')->nullable();
|
||||
$table->string('io_type')->nullable();
|
||||
$table->dateTime('occurred_at')->nullable();
|
||||
@ -518,6 +587,25 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
'initial_completed_at' => '2026-09-01 03:00:00',
|
||||
'last_succeeded_at' => '2026-09-11 03:00:00',
|
||||
],
|
||||
[
|
||||
'dataset_key' => 'operations_replica.rpt.jst_warehouseInfo',
|
||||
'status' => 'succeeded',
|
||||
'initial_completed_at' => '2026-09-01 03:00:00',
|
||||
'last_succeeded_at' => '2026-09-11 03:00:00',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function createWarehouseTable(): void
|
||||
{
|
||||
Schema::connection('business_mirror')->create('ops_jst_warehouse_info', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('Id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->string('name')->nullable();
|
||||
});
|
||||
DB::connection('business_mirror')->table('ops_jst_warehouse_info')->insert([
|
||||
['Id' => 1, 'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID, 'name' => '三方-南昌樵舍仓'],
|
||||
['Id' => 2, 'wms_co_id' => 15038426, 'name' => '简尚-崔桥工厂仓'],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -525,6 +613,7 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
||||
private function markReady(array $overrides = []): void
|
||||
{
|
||||
$this->insertState(array_merge([
|
||||
'scope_version' => 2,
|
||||
'status' => 'succeeded',
|
||||
'run_mode' => null,
|
||||
'active_header_generation' => self::HEADER_GENERATION,
|
||||
|
||||
@ -479,6 +479,7 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->unsignedTinyInteger('scope_version')->default(1);
|
||||
$table->string('status')->default('pending');
|
||||
$table->string('run_mode')->nullable();
|
||||
$table->string('sync_phase')->nullable();
|
||||
@ -526,6 +527,7 @@ class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
|
||||
$table->unsignedBigInteger('id');
|
||||
$table->unsignedBigInteger('io_id');
|
||||
$table->unsignedBigInteger('wms_co_id');
|
||||
$table->unsignedBigInteger('wh_id')->default(0);
|
||||
$table->string('status')->nullable();
|
||||
$table->string('io_type')->nullable();
|
||||
$table->dateTime('occurred_at')->nullable();
|
||||
|
||||
@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
{
|
||||
public function test_header_queries_use_the_fixed_source_table_warehouse_and_id_window(): void
|
||||
public function test_header_queries_cover_all_warehouses_with_a_bounded_id_window(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$query->scalarResult = 987654;
|
||||
@ -20,11 +20,12 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
|
||||
$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->assertStringNotContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
|
||||
$this->assertSame([], $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->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
|
||||
$this->assertStringContainsString('h.[wh_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']);
|
||||
@ -32,7 +33,7 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
$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']);
|
||||
$this->assertSame([100, 200, '2026-08-01'], $query->selectCalls[0]['bindings']);
|
||||
}
|
||||
|
||||
public function test_items_are_parameterized_and_split_into_bounded_io_id_batches(): void
|
||||
@ -114,7 +115,7 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
$this->assertCount(2, $query->selectCalls);
|
||||
}
|
||||
|
||||
public function test_inventory_uses_target_warehouse_keyset_pagination_and_a_bounded_limit(): void
|
||||
public function test_inventory_uses_all_warehouse_keyset_pagination_and_a_bounded_limit(): void
|
||||
{
|
||||
$query = new RecordingThirdPartyWarehouseQuery();
|
||||
$query->scalarResult = 191818;
|
||||
@ -124,16 +125,16 @@ class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
|
||||
$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->assertStringNotContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
|
||||
$this->assertSame([], $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->assertStringNotContainsString('[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']);
|
||||
$this->assertSame([12350, 191818], $query->selectCalls[0]['bindings']);
|
||||
|
||||
$repository->inventoryBatch(191818, 191818, 2000);
|
||||
$this->assertCount(1, $query->selectCalls);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user