From 651bb64f593dfbde2cd3ec1257838bd301dc21c3 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Tue, 22 Sep 2026 18:40:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E8=81=9A=E6=B0=B4=E6=BD=AD?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E5=8D=95=E5=88=B0=E7=BA=BF=E4=B8=8A=E4=BB=A3?= =?UTF-8?q?=E5=8F=91=E4=BB=B7=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SyncProductPurchasePriceMirror.php | 2 +- app/Http/Controllers/Business/Pages.php | 10 +- ...e_product_purchase_price_mirror_tables.php | 9 + ...purchase_price_mirror_for_online_daifa.php | 94 ++++++ docs/上游业务镜像清单.md | 4 +- .../DingOnlineDaifaPriceMirrorRepository.php | 291 ++++++++++++++---- .../ProductPurchasePriceMirrorRepository.php | 27 +- .../ProductPurchasePriceRepository.php | 5 +- ...ProductPurchasePriceMirrorSynchronizer.php | 6 +- .../online-daifa-price.blade.php | 28 +- .../Business/OnlineDaifaPricePageTest.php | 53 +++- ...ductPurchasePriceMirrorSyncCommandTest.php | 3 +- ...nlineDaifaPurchaseMirrorRepositoryTest.php | 176 +++++++++++ ...uctPurchasePriceMirrorSynchronizerTest.php | 16 + .../ProductPurchasePriceRepositoryTest.php | 4 + 15 files changed, 650 insertions(+), 78 deletions(-) create mode 100644 database/migrations/2026_09_22_100000_extend_product_purchase_price_mirror_for_online_daifa.php create mode 100644 tests/Unit/EcommerceFinance/OnlineDaifaPurchaseMirrorRepositoryTest.php diff --git a/app/Console/Commands/SyncProductPurchasePriceMirror.php b/app/Console/Commands/SyncProductPurchasePriceMirror.php index f657358b..ea766fe9 100644 --- a/app/Console/Commands/SyncProductPurchasePriceMirror.php +++ b/app/Console/Commands/SyncProductPurchasePriceMirror.php @@ -16,7 +16,7 @@ class SyncProductPurchasePriceMirror extends Command {--batch= : 每批最多读取行数} {--max-batches= : 本轮最多批次数,0 表示直到完成}'; - protected $description = '同步采购单管理的普通商品单价,并保留可断点的共享业务镜像'; + protected $description = '同步采购单管理明细和普通商品单价,并保留可断点的共享业务镜像'; public function handle( ProductPurchasePriceMirrorRepository $mirror, diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 1bc354b1..d8e77296 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1513,6 +1513,7 @@ class Pages extends Controller 'onlineDaifaStatus' => $result['status'] ?? 'unknown', 'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null, 'onlineDaifaSummary' => $result['summary'] ?? [], + 'onlineDaifaSources' => $result['source_statuses'] ?? [], 'onlineDaifaError' => null, ]; } catch (Throwable $exception) { @@ -1527,6 +1528,7 @@ class Pages extends Controller 'onlineDaifaStatus' => 'unavailable', 'onlineDaifaSyncedAt' => null, 'onlineDaifaSummary' => [], + 'onlineDaifaSources' => [], 'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。', ]; } @@ -1557,9 +1559,11 @@ class Pages extends Controller $originator = trim((string) ($workflow['originatorUserName'] ?? '')) ?: trim((string) ($workflow['originatorUserId'] ?? '')); $matchedProduct = (array) ($row['matched_product'] ?? []); - $productName = ($matchedProduct['matched'] ?? false) - ? (string) ($matchedProduct['name'] ?? '') - : '(未匹配)'; + $productName = ($row['source_type'] ?? 'ding') === 'purchase' + ? '' + : (($matchedProduct['matched'] ?? false) + ? (string) ($matchedProduct['name'] ?? '') + : '(未匹配)'); fputcsv($csv, [ $this->csvIdentifier($approvalNumber), diff --git a/database/migrations/2026_08_21_120000_create_product_purchase_price_mirror_tables.php b/database/migrations/2026_08_21_120000_create_product_purchase_price_mirror_tables.php index ae3d3164..a00395f1 100644 --- a/database/migrations/2026_08_21_120000_create_product_purchase_price_mirror_tables.php +++ b/database/migrations/2026_08_21_120000_create_product_purchase_price_mirror_tables.php @@ -27,6 +27,7 @@ return new class extends Migration $table->string('source_object', 191); $table->string('status', 24)->default('pending'); $table->string('run_mode', 24)->nullable(); + $table->unsignedTinyInteger('projection_version')->default(2); $table->uuid('generation')->nullable(); $table->dateTime('window_started_at', 6)->nullable(); $table->dateTime('window_ends_at', 6)->nullable(); @@ -47,11 +48,15 @@ return new class extends Migration $schema->create('product_purchase_price_mirror_rows', function (Blueprint $table): void { $table->unsignedBigInteger('item_id')->primary(); $table->unsignedBigInteger('purchase_order_id')->index(); + $table->unsignedBigInteger('source_order_no')->nullable()->index(); + $table->string('supplier_name', 255)->nullable(); $table->string('sku_id', 255)->nullable()->index(); $table->string('style_id', 255)->nullable()->index(); + $table->decimal('quantity', 20, 4)->nullable(); $table->decimal('unit_price', 20, 4)->nullable(); $table->string('document_status', 50)->nullable()->index(); $table->text('labels')->nullable(); + $table->dateTime('document_created_at', 6)->nullable()->index(); $table->dateTime('finished_at', 6)->nullable()->index(); $table->dateTime('purchase_updated_at', 6)->index(); $table->uuid('_mirror_generation')->nullable()->index(); @@ -63,6 +68,10 @@ return new class extends Migration ['document_status', 'sku_id', 'finished_at'], 'product_purchase_price_latest_lookup', ); + $table->index( + ['document_status', 'document_created_at', 'item_id'], + 'product_purchase_online_daifa_lookup', + ); }); } } diff --git a/database/migrations/2026_09_22_100000_extend_product_purchase_price_mirror_for_online_daifa.php b/database/migrations/2026_09_22_100000_extend_product_purchase_price_mirror_for_online_daifa.php new file mode 100644 index 00000000..306c95f2 --- /dev/null +++ b/database/migrations/2026_09_22_100000_extend_product_purchase_price_mirror_for_online_daifa.php @@ -0,0 +1,94 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if ($schema->hasTable('product_purchase_price_sync_states') + && ! $schema->hasColumn('product_purchase_price_sync_states', 'projection_version')) { + $schema->table('product_purchase_price_sync_states', function (Blueprint $table): void { + // Existing rows use projection 1 and must complete a fresh full pass + // before purchase-management rows are exposed on the page. + $table->unsignedTinyInteger('projection_version')->default(1)->after('run_mode'); + }); + } + + if (! $schema->hasTable('product_purchase_price_mirror_rows')) { + return; + } + + $missing = array_filter([ + 'source_order_no' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'source_order_no'), + 'supplier_name' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'supplier_name'), + 'quantity' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'quantity'), + 'document_created_at' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'document_created_at'), + ]); + + if ($missing === []) { + return; + } + + $schema->table('product_purchase_price_mirror_rows', function (Blueprint $table) use ($missing): void { + if (isset($missing['source_order_no'])) { + $table->unsignedBigInteger('source_order_no')->nullable()->after('purchase_order_id'); + } + if (isset($missing['supplier_name'])) { + $table->string('supplier_name', 255)->nullable()->after('source_order_no'); + } + if (isset($missing['quantity'])) { + $table->decimal('quantity', 20, 4)->nullable()->after('style_id'); + } + if (isset($missing['document_created_at'])) { + $table->dateTime('document_created_at', 6)->nullable()->after('labels'); + } + }); + + $schema->table('product_purchase_price_mirror_rows', function (Blueprint $table): void { + $table->index('source_order_no', 'product_purchase_source_order_index'); + $table->index( + ['document_status', 'document_created_at', 'item_id'], + 'product_purchase_online_daifa_lookup', + ); + }); + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if ($schema->hasTable('product_purchase_price_mirror_rows')) { + $schema->table('product_purchase_price_mirror_rows', function (Blueprint $table): void { + $table->dropIndex('product_purchase_source_order_index'); + $table->dropIndex('product_purchase_online_daifa_lookup'); + $table->dropColumn(['source_order_no', 'supplier_name', 'quantity', 'document_created_at']); + }); + } + + if ($schema->hasTable('product_purchase_price_sync_states')) { + $schema->table('product_purchase_price_sync_states', function (Blueprint $table): void { + $table->dropColumn('projection_version'); + }); + } + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index c34c7269..9e48daf3 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -14,7 +14,7 @@ | 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 | | 本次接入 | 聚水潭进销存共享镜像读取 `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一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 | +| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价。线上代发价格页面另读取 `Confirmed` / `Finished` 采购明细,以 `source_o_id` 关联 `ops_jst_orders.o_id`,展示线上单号、店铺、供应商、商品编码、数量、单价并标记“采购单管理”;页面请求不直连上游。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | | 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 | | 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;财务经营报表的小额打款页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 | @@ -94,7 +94,7 @@ 4. 普通商品在最近完整校准超过 168 小时后,于周日 02:00-08:00 低峰窗口启动全量校准;一旦启动,后续每分钟从稳定游标继续,直至完整结束。首次全量由 Codex 备份后自主执行,定时任务保持既有初始化开关。 5. 除专用数据集外,通用任务每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。 6. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。 -7. 普通商品的采购单价同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;页面按商品编码选择最近完成的 1688 一键代发价格。同步保存全部状态,使完成单后续取消时也能撤回旧价格;首次全量前只显示普通商品自身采购价。 +7. 普通商品的采购单同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;镜像保存来源单号、供应商、数量、单价和全部状态。普通商品页面按商品编码选择最近完成的 1688 一键代发价格;线上代发价格页面只展示 `Confirmed` / `Finished`,并在共享镜像内关联订单。完成单后续取消时可撤回旧价格;新增投影字段完成新一轮全量前不展示采购单管理数据。 8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。 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。 diff --git a/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php index 33a6f88c..c06ab03a 100644 --- a/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php @@ -4,16 +4,17 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Services\DingPayableWorkflowStatus; use RuntimeException; /** - * 线上代发业务报销流程的共享镜像读取与按商品ID关联。 + * 线上代发价格的统一镜像查询。 * - * 数据由 DingPayableWorkflowMirrorSynchronizer(ding_online_daifa_price 配置) - * 从钉钉 OA 审批同步到 ding_online_daifa_price_mirror 宽表。本仓库只在镜像库 - * 读取,并依据 商品ID(product_id)关联共享业务镜像的商品目录,页面不直连上游。 + * 钉钉线上代发审批和聚水潭采购单先分别同步到共享业务镜像,再在这里合并。 + * 页面请求只读共享镜像,不跨来源实时查询上游。 */ class DingOnlineDaifaPriceMirrorRepository { @@ -21,8 +22,13 @@ class DingOnlineDaifaPriceMirrorRepository public const TARGET_TABLE = 'ding_online_daifa_price_mirror'; + private const ORDER_DATASET_KEY = 'operations_replica.rpt.jst_Orders'; + private readonly ConnectionInterface $connection; + /** @var array>|null */ + private ?array $sourceStatusCache = null; + public function __construct(GenericBusinessMirrorRepository $mirror) { $this->connection = $mirror->connection(); @@ -30,18 +36,12 @@ class DingOnlineDaifaPriceMirrorRepository public function ready(): bool { - $schema = Schema::connection($this->connection->getName()); - if (! $schema->hasTable(self::TARGET_TABLE) || ! $schema->hasTable('business_mirror_table_states')) { - return false; - } - - $state = $this->connection->table('business_mirror_table_states') - ->where('dataset_key', self::DATASET_KEY)->first(); - - return $state !== null && $state->initial_completed_at !== null; + return collect($this->sourceStatuses())->contains( + static fn (array $source): bool => (bool) ($source['ready'] ?? false), + ); } - /** @return array{data:list>,total:int,status:string,last_synced_at:?string,summary:array} */ + /** @return array{data:list>,total:int,status:string,last_synced_at:?string,summary:array,source_statuses:array>} */ public function paginate(array $filters, int $page, int $perPage): array { if (! $this->ready()) { @@ -50,38 +50,37 @@ class DingOnlineDaifaPriceMirrorRepository $page = max($page, 1); $perPage = min(max($perPage, 1), 100); - $state = $this->connection->table('business_mirror_table_states')->where('dataset_key', self::DATASET_KEY)->first(); - - // The synchronized dataset normally contains one configured status, so - // count, totals, and paging can stay in SQL instead of loading all rows. - $onlyStatus = strtoupper(trim((string) config('upstream_mirrors.ding_online_daifa_price.only_status', ''))); - $statusFilter = strtoupper(trim((string) ($filters['status'] ?? ''))); - $statusMismatch = $statusFilter !== '' && $onlyStatus !== '' && $statusFilter !== $onlyStatus; - - $total = $statusMismatch ? 0 : (int) $this->filteredQuery($filters)->count(); + $total = (int) $this->filteredQuery($filters)->count(); $summary = [ 'count' => 0, 'matched_count' => 0, + 'matchable_count' => 0, + 'purchase_count' => 0, 'daifa_amount' => 0.0, 'paid_amount' => 0.0, 'price_diff' => 0.0, ]; $pageRows = []; - if (! $statusMismatch) { + if ($total > 0) { $aggregate = $this->filteredQuery($filters) ->selectRaw( 'COUNT(*) AS row_count, ' - . "SUM(CASE WHEN product_id IS NOT NULL AND product_id <> '' AND product_name IS NOT NULL AND product_name <> '' THEN 1 ELSE 0 END) AS matched_count, " - . 'COALESCE(SUM(daifa_amount), 0) AS daifa_amount, ' - . 'COALESCE(SUM(paid_amount), 0) AS paid_amount, ' - . 'COALESCE(SUM(price_diff), 0) AS price_diff' + . "SUM(CASE WHEN source_type = 'purchase' THEN 1 ELSE 0 END) AS purchase_count, " + . "SUM(CASE WHEN source_type = 'ding' THEN 1 ELSE 0 END) AS matchable_count, " + . "SUM(CASE WHEN source_type = 'ding' AND product_id IS NOT NULL AND product_id <> '' " + . "AND product_name IS NOT NULL AND product_name <> '' THEN 1 ELSE 0 END) AS matched_count, " + . "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN daifa_amount ELSE 0 END), 0) AS daifa_amount, " + . "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN paid_amount ELSE 0 END), 0) AS paid_amount, " + . "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN price_diff ELSE 0 END), 0) AS price_diff" ) ->first(); $summary = [ 'count' => (int) ($aggregate->row_count ?? 0), 'matched_count' => (int) ($aggregate->matched_count ?? 0), + 'matchable_count' => (int) ($aggregate->matchable_count ?? 0), + 'purchase_count' => (int) ($aggregate->purchase_count ?? 0), 'daifa_amount' => round((float) ($aggregate->daifa_amount ?? 0), 2), 'paid_amount' => round((float) ($aggregate->paid_amount ?? 0), 2), 'price_diff' => round((float) ($aggregate->price_diff ?? 0), 2), @@ -97,16 +96,22 @@ class DingOnlineDaifaPriceMirrorRepository $data = []; foreach ($pageRows as $row) { - $workflow = $this->decode((string) $row->workflow_payload, []); - $data[] = $this->presentRow($row, $workflow); + $data[] = $this->presentRow($row); } + $sources = $this->sourceStatuses(); + $readySources = array_filter($sources, static fn (array $source): bool => (bool) ($source['ready'] ?? false)); + $synced = array_values(array_filter(array_column($readySources, 'last_synced_at'))); + return [ 'data' => $data, 'total' => $total, - 'status' => (string) ($state->status ?? 'unknown'), - 'last_synced_at' => $state->last_succeeded_at ? (string) $state->last_succeeded_at : null, + 'status' => count($readySources) > 1 + ? 'available' + : (string) (reset($readySources)['status'] ?? 'unknown'), + 'last_synced_at' => $synced === [] ? null : max($synced), 'summary' => $summary, + 'source_statuses' => $sources, ]; } @@ -117,7 +122,6 @@ class DingOnlineDaifaPriceMirrorRepository throw new RuntimeException('business_mirror_dataset_not_ready'); } - $statusFilter = strtoupper(trim((string) ($filters['status'] ?? ''))); $rows = $this->filteredQuery($filters) ->orderByDesc('workflow_created_at') ->orderByDesc('process_instance_id') @@ -125,18 +129,13 @@ class DingOnlineDaifaPriceMirrorRepository ->cursor(); foreach ($rows as $row) { - $workflow = $this->decode((string) $row->workflow_payload, []); - if (! $this->matchesStatus($workflow, $statusFilter)) { - continue; - } - - yield $this->presentRow($row, $workflow); + yield $this->presentRow($row); } } private function filteredQuery(array $filters): Builder { - $query = $this->connection->table(self::TARGET_TABLE); + $query = $this->combinedQuery(); if (($filters['product_id'] ?? '') !== '') { $query->where('product_id', (string) $filters['product_id']); @@ -155,58 +154,236 @@ class DingOnlineDaifaPriceMirrorRepository $query->where(function ($q) use ($keyword): void { $q->where('process_instance_id', 'like', '%' . $keyword . '%') ->orWhere('daifa_trade_no', 'like', '%' . $keyword . '%') - ->orWhere('shop_trade_no', 'like', '%' . $keyword . '%'); + ->orWhere('shop_trade_no', 'like', '%' . $keyword . '%') + ->orWhere('source_order_no', 'like', '%' . $keyword . '%'); }); } + if (($filters['status'] ?? '') !== '') { + $query->where('status_key', strtoupper((string) $filters['status'])); + } if (($filters['created_from'] ?? '') !== '') { $query->where('workflow_created_at', '>=', (string) $filters['created_from']); } if (($filters['created_to'] ?? '') !== '') { - $query->where('workflow_created_at', '<', \Illuminate\Support\Carbon::parse((string) $filters['created_to'])->addDay()->startOfDay()->format('Y-m-d H:i:s')); + $query->where( + 'workflow_created_at', + '<', + Carbon::parse((string) $filters['created_to'])->addDay()->startOfDay()->format('Y-m-d H:i:s'), + ); } return $query; } - private function matchesStatus(array $workflow, string $statusFilter): bool + private function combinedQuery(): Builder { - return $statusFilter === '' || DingPayableWorkflowStatus::key($workflow) === $statusFilter; + $statuses = $this->sourceStatuses(); + $queries = []; + + if ((bool) ($statuses['ding']['ready'] ?? false)) { + $queries[] = $this->dingQuery(); + } + if ((bool) ($statuses['purchase']['ready'] ?? false)) { + $queries[] = $this->purchaseQuery(); + } + + if ($queries === []) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $union = array_shift($queries); + foreach ($queries as $query) { + $union->unionAll($query); + } + + return $this->connection->query()->fromSub($union, 'online_daifa_rows'); + } + + private function dingQuery(): Builder + { + $status = strtoupper(trim((string) config( + 'upstream_mirrors.ding_online_daifa_price.only_status', + 'COMPLETED', + ))) ?: 'COMPLETED'; + + return $this->connection->table(self::TARGET_TABLE . ' as d')->select([ + DB::raw("'ding' AS source_type"), + 'd.process_instance_id', + 'd.detail_index', + DB::raw('NULL AS purchase_order_id'), + DB::raw('NULL AS source_order_no'), + 'd.product_id', + 'd.product_name', + 'd.daifa_trade_no', + 'd.shop_trade_no', + 'd.shop_name', + 'd.payee_name', + DB::raw("CASE WHEN d.payee_name = '宁波顾家望业家居有限公司' THEN '刘萧代发' ELSE '1688代发' END AS tag"), + 'd.supplier_name', + 'd.material_no', + 'd.ship_qty', + 'd.paid_amount', + 'd.daifa_amount', + 'd.price_diff', + 'd.workflow_payload', + 'd.scheme_payload', + 'd.task_payload', + DB::raw("'" . $status . "' AS status_key"), + 'd.workflow_created_at', + ]); + } + + private function purchaseQuery(): Builder + { + return $this->connection->table('product_purchase_price_mirror_rows as p') + ->leftJoin('ops_jst_orders as o', 'o.o_id', '=', 'p.source_order_no') + ->whereIn('p.document_status', ['Confirmed', 'Finished']) + ->select([ + DB::raw("'purchase' AS source_type"), + 'p.purchase_order_id as process_instance_id', + 'p.item_id as detail_index', + 'p.purchase_order_id', + 'p.source_order_no', + DB::raw("'' AS product_id"), + DB::raw('NULL AS product_name'), + DB::raw("'' AS daifa_trade_no"), + 'o.so_id as shop_trade_no', + 'o.shop_name', + DB::raw("'' AS payee_name"), + DB::raw("'采购单管理' AS tag"), + 'p.supplier_name', + 'p.sku_id as material_no', + 'p.quantity as ship_qty', + DB::raw('NULL AS paid_amount'), + 'p.unit_price as daifa_amount', + DB::raw('NULL AS price_diff'), + DB::raw('NULL AS workflow_payload'), + DB::raw('NULL AS scheme_payload'), + DB::raw('NULL AS task_payload'), + DB::raw('UPPER(p.document_status) AS status_key'), + DB::raw('COALESCE(p.document_created_at, p.purchase_updated_at) AS workflow_created_at'), + ]); + } + + /** @return array> */ + private function sourceStatuses(): array + { + if ($this->sourceStatusCache !== null) { + return $this->sourceStatusCache; + } + + $schema = Schema::connection($this->connection->getName()); + $dingState = null; + if ($schema->hasTable(self::TARGET_TABLE) && $schema->hasTable('business_mirror_table_states')) { + $dingState = $this->connection->table('business_mirror_table_states') + ->where('dataset_key', self::DATASET_KEY) + ->first(); + } + $dingReady = $dingState !== null && $dingState->initial_completed_at !== null; + + $purchaseState = null; + $orderState = null; + $requiredPurchaseColumns = [ + 'source_order_no', 'supplier_name', 'quantity', 'document_created_at', + ]; + $purchaseSchemaReady = $schema->hasTable('product_purchase_price_mirror_rows') + && $schema->hasTable('product_purchase_price_sync_states') + && $schema->hasTable('ops_jst_orders') + && $schema->hasColumns('product_purchase_price_mirror_rows', $requiredPurchaseColumns); + + if ($purchaseSchemaReady) { + $purchaseState = $this->connection->table('product_purchase_price_sync_states') + ->where('dataset_key', ProductPurchasePriceMirrorRepository::DATASET_KEY) + ->first(); + $orderState = $schema->hasTable('business_mirror_table_states') + ? $this->connection->table('business_mirror_table_states') + ->where('dataset_key', self::ORDER_DATASET_KEY) + ->first() + : null; + } + + $purchaseReady = $purchaseState !== null + && $purchaseState->initial_completed_at !== null + && (int) ($purchaseState->projection_version ?? 1) + >= ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION + && $orderState !== null + && $orderState->initial_completed_at !== null; + + return $this->sourceStatusCache = [ + 'ding' => [ + 'label' => '钉钉线上代发审批', + 'ready' => $dingReady, + 'status' => $dingState === null ? 'pending' : (string) $dingState->status, + 'last_synced_at' => $dingState?->last_succeeded_at === null + ? null + : (string) $dingState->last_succeeded_at, + ], + 'purchase' => [ + 'label' => '聚水潭采购单管理', + 'ready' => $purchaseReady, + 'status' => $purchaseReady + ? (string) $purchaseState->status + : ($purchaseSchemaReady ? 'requires_full_sync' : 'pending'), + 'last_synced_at' => $purchaseState?->last_succeeded_at === null + ? null + : (string) $purchaseState->last_succeeded_at, + ], + ]; } /** @return array */ - private function presentRow(object $row, array $workflow): array + private function presentRow(object $row): array { + $sourceType = (string) ($row->source_type ?? 'ding'); + $purchase = $sourceType === 'purchase'; + $workflow = $purchase + ? [ + 'businessId' => (string) ($row->purchase_order_id ?? ''), + 'title' => '采购单管理', + 'status' => (string) ($row->status_key ?? ''), + ] + : $this->decode((string) ($row->workflow_payload ?? ''), []); $productId = (string) ($row->product_id ?? ''); - // 商品名称已在同步阶段按 商品ID 关联商品目录并写入镜像宽表 product_name 列, - // 页面直接读取,避免对 2500 万行的 ops_jst_sku_map 做临时 JOIN 导致超时。 $resolvedName = $this->cleanName($row->product_name ?? null); - $product = $productId !== '' && $resolvedName !== null + $product = ! $purchase && $productId !== '' && $resolvedName !== null ? ['matched' => true, 'name' => $resolvedName, 'sku_id' => null, 'i_id' => null] : ['matched' => false, 'name' => null, 'sku_id' => null, 'i_id' => null]; + $statusKey = $purchase + ? strtoupper((string) ($row->status_key ?? '')) + : DingPayableWorkflowStatus::key($workflow); return [ + 'source_type' => $sourceType, 'process_instance_id' => (string) $row->process_instance_id, 'detail_index' => (int) $row->detail_index, + 'purchase_order_id' => $row->purchase_order_id === null ? null : (int) $row->purchase_order_id, + 'source_order_no' => $row->source_order_no === null ? null : (string) $row->source_order_no, 'product_id' => $productId, 'daifa_trade_no' => (string) ($row->daifa_trade_no ?? ''), 'shop_trade_no' => (string) ($row->shop_trade_no ?? ''), 'shop_name' => (string) ($row->shop_name ?? ''), 'payee_name' => (string) ($row->payee_name ?? ''), - 'tag' => ((string) ($row->payee_name ?? '')) === '宁波顾家望业家居有限公司' - ? '刘萧代发' - : '1688代发', + 'tag' => (string) ($row->tag ?? ''), 'supplier_name' => (string) ($row->supplier_name ?? ''), 'material_no' => (string) ($row->material_no ?? ''), - 'ship_qty' => $row->ship_qty === null ? null : (int) $row->ship_qty, + 'ship_qty' => $row->ship_qty === null ? null : (float) $row->ship_qty, 'paid_amount' => $row->paid_amount === null ? null : (float) $row->paid_amount, 'daifa_amount' => $row->daifa_amount === null ? null : (float) $row->daifa_amount, 'price_diff' => $row->price_diff === null ? null : (float) $row->price_diff, 'workflow' => $workflow, - 'scheme' => $this->decode((string) ($row->scheme_payload ?? '[]'), []), - 'tasks' => $this->decode((string) ($row->task_payload ?? '[]'), []), - 'status_key' => DingPayableWorkflowStatus::key($workflow), - 'status_label' => DingPayableWorkflowStatus::label($workflow), + 'scheme' => $purchase ? [] : $this->decode((string) ($row->scheme_payload ?? ''), []), + 'tasks' => $purchase ? [] : $this->decode((string) ($row->task_payload ?? ''), []), + 'status_key' => $statusKey, + 'status_label' => $purchase + ? match ($statusKey) { + 'CONFIRMED' => '已确认', + 'FINISHED' => '已完成', + default => $statusKey, + } + : DingPayableWorkflowStatus::label($workflow), 'matched_product' => $product, + 'order_matched' => ! $purchase + || ((string) ($row->shop_trade_no ?? '') !== '' && (string) ($row->shop_name ?? '') !== ''), 'workflow_created_at' => (string) ($row->workflow_created_at ?? ''), ]; } @@ -222,6 +399,7 @@ class DingOnlineDaifaPriceMirrorRepository return null; } $name = trim((string) $value); + return $name === '' ? null : $name; } @@ -231,6 +409,7 @@ class DingOnlineDaifaPriceMirrorRepository return $fallback; } $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : $fallback; } } diff --git a/modules/EcommerceFinance/Repositories/ProductPurchasePriceMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductPurchasePriceMirrorRepository.php index 1c5ff984..fc73ff97 100644 --- a/modules/EcommerceFinance/Repositories/ProductPurchasePriceMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductPurchasePriceMirrorRepository.php @@ -12,6 +12,8 @@ class ProductPurchasePriceMirrorRepository { public const DATASET_KEY = 'product-inventory.purchase-order-prices'; + public const CURRENT_PROJECTION_VERSION = 2; + private readonly ConnectionInterface $connection; public function __construct(DatabaseManager $database) @@ -33,7 +35,9 @@ class ProductPurchasePriceMirrorRepository { $state = $this->state(); - return is_array($state) && ($state['initial_completed_at'] ?? null) !== null; + return is_array($state) + && ($state['initial_completed_at'] ?? null) !== null + && (int) ($state['projection_version'] ?? 1) >= self::CURRENT_PROJECTION_VERSION; } /** @return array|null */ @@ -67,6 +71,7 @@ class ProductPurchasePriceMirrorRepository 'status' => 'running', 'run_mode' => $mode, 'generation' => $generation, + 'projection_version' => self::CURRENT_PROJECTION_VERSION, 'last_attempted_at' => $now, 'last_error_code' => null, 'updated_at' => $now, @@ -104,11 +109,15 @@ class ProductPurchasePriceMirrorRepository $values = [ 'item_id' => (int) $row['item_id'], 'purchase_order_id' => (int) $row['purchase_order_id'], + 'source_order_no' => $this->nullablePositiveInteger($row['source_order_no'] ?? null), + 'supplier_name' => $this->nullableString($row['supplier_name'] ?? null), 'sku_id' => $this->nullableString($row['sku_id'] ?? null), 'style_id' => $this->nullableString($row['style_id'] ?? null), + 'quantity' => $row['quantity'] ?? null, 'unit_price' => $row['unit_price'] ?? null, 'document_status' => $this->nullableString($row['document_status'] ?? null), 'labels' => $this->nullableString($row['labels'] ?? null), + 'document_created_at' => $row['document_created_at'] ?? null, 'finished_at' => $row['finished_at'] ?? null, 'purchase_updated_at' => $row['purchase_updated_at'], ]; @@ -130,8 +139,9 @@ class ProductPurchasePriceMirrorRepository $mapped, ['item_id'], [ - 'purchase_order_id', 'sku_id', 'style_id', 'unit_price', - 'document_status', 'labels', 'finished_at', 'purchase_updated_at', + 'purchase_order_id', 'source_order_no', 'supplier_name', + 'sku_id', 'style_id', 'quantity', 'unit_price', + 'document_status', 'labels', 'document_created_at', 'finished_at', 'purchase_updated_at', '_mirror_generation', 'row_hash', '_mirror_synced_at', 'updated_at', ], ); @@ -211,4 +221,15 @@ class ProductPurchasePriceMirrorRepository return $value === '' ? null : $value; } + + private function nullablePositiveInteger(mixed $value): ?int + { + if ($value === null || $value === '' || ! is_numeric($value)) { + return null; + } + + $value = (int) $value; + + return $value > 0 ? $value : null; + } } diff --git a/modules/EcommerceFinance/Repositories/ProductPurchasePriceRepository.php b/modules/EcommerceFinance/Repositories/ProductPurchasePriceRepository.php index 713674d6..b297a5a7 100644 --- a/modules/EcommerceFinance/Repositories/ProductPurchasePriceRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductPurchasePriceRepository.php @@ -34,8 +34,11 @@ class ProductPurchasePriceRepository $sql = 'SELECT TOP (' . $limit . ') ' . 'i.[Id] AS item_id, p.[po_id] AS purchase_order_id, ' - . 'i.[sku_id] AS sku_id, i.[i_id] AS style_id, i.[price] AS unit_price, ' + . 'CONVERT(varchar(64), CONVERT(decimal(38, 0), p.[source_o_id])) AS source_order_no, ' + . 'p.[seller] AS supplier_name, ' + . 'i.[sku_id] AS sku_id, i.[i_id] AS style_id, i.[qty] AS quantity, i.[price] AS unit_price, ' . 'p.[status] AS document_status, p.[labels] AS labels, ' + . 'CONVERT(varchar(27), p.[createTime], 126) AS document_created_at, ' . 'CONVERT(varchar(27), p.[finish_time], 126) AS finished_at, ' . 'CONVERT(varchar(27), p.[updateTime], 126) AS purchase_updated_at ' . 'FROM [dbo].[jst_purchase] p ' diff --git a/modules/EcommerceFinance/Services/ProductPurchasePriceMirrorSynchronizer.php b/modules/EcommerceFinance/Services/ProductPurchasePriceMirrorSynchronizer.php index 0690d2aa..1a827005 100644 --- a/modules/EcommerceFinance/Services/ProductPurchasePriceMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/ProductPurchasePriceMirrorSynchronizer.php @@ -25,7 +25,10 @@ class ProductPurchasePriceMirrorSynchronizer } $state = $this->mirror->state(); - if (! $full && ($state['initial_completed_at'] ?? null) === null) { + if (! $full && ( + ($state['initial_completed_at'] ?? null) === null + || (int) ($state['projection_version'] ?? 1) < ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION + )) { return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 0, 'rows' => 0]; } @@ -33,6 +36,7 @@ class ProductPurchasePriceMirrorSynchronizer $resume = is_array($state) && ($state['run_mode'] ?? null) === $mode && in_array($state['status'] ?? null, ['running', 'failed'], true) + && (int) ($state['projection_version'] ?? 1) >= ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION && ($state['window_started_at'] ?? null) !== null && ($state['window_ends_at'] ?? null) !== null; $generation = $resume && is_string($state['generation'] ?? null) diff --git a/resources/views/business/pages/product-inventory/online-daifa-price.blade.php b/resources/views/business/pages/product-inventory/online-daifa-price.blade.php index 59944951..fabe5791 100644 --- a/resources/views/business/pages/product-inventory/online-daifa-price.blade.php +++ b/resources/views/business/pages/product-inventory/online-daifa-price.blade.php @@ -4,13 +4,19 @@ $status = (string) ($onlineDaifaStatus ?? 'unknown'); $syncedAt = $onlineDaifaSyncedAt ?? null; $summary = $onlineDaifaSummary ?? []; + $sources = $onlineDaifaSources ?? []; $error = $onlineDaifaError ?? null; - $statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions(); + $statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions() + [ + 'CONFIRMED' => '已确认(采购单)', + 'FINISHED' => '已完成(采购单)', + ]; $num = static fn (mixed $value): string => $value === null || $value === '' ? '-' : (string) $value; $money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2); - $productName = static fn (array $row): string => $row['matched_product']['matched'] ?? false - ? (string) ($row['matched_product']['name'] ?? '-') - : '(未匹配)'; + $productName = static fn (array $row): string => ($row['source_type'] ?? 'ding') === 'purchase' + ? '-' + : (($row['matched_product']['matched'] ?? false) + ? (string) ($row['matched_product']['name'] ?? '-') + : '(未匹配)'); $payeeScope = trim((string) config('upstream_mirrors.ding_online_daifa_price.row_filters.payee_name', '')); @endphp @@ -49,7 +55,7 @@
- +
@@ -96,10 +102,13 @@ @endif
- 固定流程:线上代发业务报销流程(钉钉 OA 审批) + 数据来源:钉钉线上代发审批、聚水潭采购单管理(已确认/已完成) @if ($payeeScope !== '') 固定收款人:{{ $payeeScope }} @endif 镜像状态:{{ $status }} @if ($syncedAt) 最近同步:{{ $syncedAt }} @endif + @foreach ($sources as $source) + {{ $source['label'] ?? '数据源' }}:{{ ($source['ready'] ?? false) ? ($source['status'] ?? 'available') : '同步准备中' }} + @endforeach 共 {{ $rows?->total() ?? 0 }} 条
@@ -107,7 +116,8 @@
代发订单金额合计{{ $money($summary['daifa_amount'] ?? null) }}
订单实付金额合计{{ $money($summary['paid_amount'] ?? null) }}
差价合计{{ $money($summary['price_diff'] ?? null) }}
-
已匹配商品目录{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['count'] ?? 0) }}
+
采购单管理{{ (int) ($summary['purchase_count'] ?? 0) }}
+
已匹配商品目录{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['matchable_count'] ?? $summary['count'] ?? 0) }}
@@ -133,9 +143,9 @@ {{ $row['tag'] ?? '' }} {{ $num($row['product_id']) }} {{ $productName($row) }} - {{ $num($row['shop_name']) }} + {{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? '(未匹配订单)' : $num($row['shop_name']) }} {{ $num($row['daifa_trade_no']) }} - {{ $num($row['shop_trade_no']) }} + {{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? '(未匹配订单)' : $num($row['shop_trade_no']) }} {{ $num($row['ship_qty']) }} {{ $money($row['paid_amount']) }} {{ $money($row['daifa_amount']) }} diff --git a/tests/Feature/Business/OnlineDaifaPricePageTest.php b/tests/Feature/Business/OnlineDaifaPricePageTest.php index bf951f33..341d3680 100644 --- a/tests/Feature/Business/OnlineDaifaPricePageTest.php +++ b/tests/Feature/Business/OnlineDaifaPricePageTest.php @@ -62,7 +62,7 @@ class OnlineDaifaPricePageTest extends FeatureTestCase 'page' => 'online-daifa-price', ]))->assertOk() ->assertSee('线上代发价格') - ->assertSee('固定流程:线上代发业务报销流程(钉钉 OA 审批)') + ->assertSee('数据来源:钉钉线上代发审批、聚水潭采购单管理(已确认/已完成)') ->assertDontSee('固定收款人:') ->assertSee('宁波顾家望业家居有限公司') ->assertSee('镜像状态:succeeded') @@ -79,6 +79,57 @@ class OnlineDaifaPricePageTest extends FeatureTestCase ->assertDontSee('展开表单'); } + public function test_online_daifa_price_page_places_purchase_fields_in_existing_columns(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $repository = Mockery::mock(DingOnlineDaifaPriceMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->andReturn([ + 'data' => [[ + 'source_type' => 'purchase', + 'process_instance_id' => '907329', + 'detail_index' => 1, + 'product_id' => '', + 'daifa_trade_no' => '', + 'shop_trade_no' => '110212448119316609', + 'shop_name' => '得物-米客米家家居旗舰店', + 'payee_name' => '', + 'tag' => '采购单管理', + 'supplier_name' => '霸州市亚锦五金制品厂', + 'material_no' => 'DF-C1ZDY000202', + 'ship_qty' => 1, + 'paid_amount' => null, + 'daifa_amount' => 19.3, + 'price_diff' => null, + 'workflow' => ['businessId' => '907329', 'title' => '采购单管理'], + 'status_label' => '已确认', + 'matched_product' => ['matched' => false, 'name' => null], + 'order_matched' => true, + 'workflow_created_at' => '2026-09-22 16:30:03', + ]], + 'total' => 1, + 'status' => 'available', + 'last_synced_at' => '2026-09-22 17:35:11', + 'summary' => ['count' => 1, 'purchase_count' => 1, 'matchable_count' => 0], + 'source_statuses' => [ + 'purchase' => ['label' => '聚水潭采购单管理', 'ready' => true, 'status' => 'succeeded'], + ], + ]); + $this->app->instance(DingOnlineDaifaPriceMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'product-inventory', + 'page' => 'online-daifa-price', + ]))->assertOk() + ->assertSee('110212448119316609') + ->assertSee('得物-米客米家家居旗舰店') + ->assertSee('霸州市亚锦五金制品厂') + ->assertSee('DF-C1ZDY000202') + ->assertSee('19.30') + ->assertSee('采购单管理') + ->assertSee('已确认') + ->assertDontSee('(未匹配)'); + } + public function test_online_daifa_price_page_keeps_an_unavailable_mirror_readable(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); diff --git a/tests/Feature/Console/ProductPurchasePriceMirrorSyncCommandTest.php b/tests/Feature/Console/ProductPurchasePriceMirrorSyncCommandTest.php index 3a6bbdb2..90745393 100644 --- a/tests/Feature/Console/ProductPurchasePriceMirrorSyncCommandTest.php +++ b/tests/Feature/Console/ProductPurchasePriceMirrorSyncCommandTest.php @@ -42,6 +42,7 @@ class ProductPurchasePriceMirrorSyncCommandTest extends TestCase 'run_mode' => 'incremental', 'initial_completed_at' => '2026-08-14 10:00:00', 'last_full_succeeded_at' => '2026-08-17 02:00:00', + 'projection_version' => ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION, ]); $synchronizer = Mockery::mock(ProductPurchasePriceMirrorSynchronizer::class); $synchronizer->shouldReceive('sync') @@ -80,7 +81,7 @@ class ProductPurchasePriceMirrorSyncCommandTest extends TestCase $this->app->instance(ProductPurchasePriceMirrorSynchronizer::class, $synchronizer); $this->artisan('business-mirror:sync-product-purchase-prices') - ->expectsOutputToContain('需经授权后显式使用 --full') + ->expectsOutputToContain('完成备份后显式使用 --full') ->assertSuccessful(); } } diff --git a/tests/Unit/EcommerceFinance/OnlineDaifaPurchaseMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/OnlineDaifaPurchaseMirrorRepositoryTest.php new file mode 100644 index 00000000..8ac18a0b --- /dev/null +++ b/tests/Unit/EcommerceFinance/OnlineDaifaPurchaseMirrorRepositoryTest.php @@ -0,0 +1,176 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + ]); + DB::purge('business_mirror'); + + $this->createSchema(); + $this->seedRows(); + } + + public function test_it_maps_confirmed_and_finished_purchase_rows_and_keeps_missing_orders(): void + { + $result = app(DingOnlineDaifaPriceMirrorRepository::class)->paginate([], 1, 25); + + $this->assertSame(2, $result['total']); + $this->assertSame(2, $result['summary']['purchase_count']); + $this->assertSame(0.0, $result['summary']['daifa_amount']); + + $confirmed = collect($result['data'])->firstWhere('purchase_order_id', 907329); + $this->assertSame('110212448119316609', $confirmed['shop_trade_no']); + $this->assertSame('得物-米客米家家居旗舰店', $confirmed['shop_name']); + $this->assertSame('霸州市亚锦五金制品厂', $confirmed['supplier_name']); + $this->assertSame('DF-C1ZDY000202', $confirmed['material_no']); + $this->assertSame(1.0, $confirmed['ship_qty']); + $this->assertSame(19.3, $confirmed['daifa_amount']); + $this->assertSame('采购单管理', $confirmed['tag']); + $this->assertSame('已确认', $confirmed['status_label']); + $this->assertTrue($confirmed['order_matched']); + + $missing = collect($result['data'])->firstWhere('purchase_order_id', 907330); + $this->assertNotNull($missing); + $this->assertSame('', $missing['shop_trade_no']); + $this->assertFalse($missing['order_matched']); + } + + public function test_purchase_filters_apply_to_page_and_export_with_the_same_scope(): void + { + $filters = [ + 'status' => 'CONFIRMED', + 'shop_name' => '得物-米客米家家居旗舰店', + 'supplier_name' => '亚锦', + 'material_no' => 'DF-C1ZDY000202', + ]; + $repository = app(DingOnlineDaifaPriceMirrorRepository::class); + + $page = $repository->paginate($filters, 1, 25); + $export = iterator_to_array($repository->exportRows($filters)); + + $this->assertSame(1, $page['total']); + $this->assertSame([907329], array_column($page['data'], 'purchase_order_id')); + $this->assertSame([907329], array_column($export, 'purchase_order_id')); + } + + private function createSchema(): void + { + $schema = Schema::connection('business_mirror'); + + $schema->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status'); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + $schema->create('product_purchase_price_sync_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status'); + $table->unsignedTinyInteger('projection_version')->default(1); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + $schema->create('product_purchase_price_mirror_rows', function (Blueprint $table): void { + $table->unsignedBigInteger('item_id')->primary(); + $table->unsignedBigInteger('purchase_order_id'); + $table->unsignedBigInteger('source_order_no')->nullable(); + $table->string('supplier_name')->nullable(); + $table->string('sku_id')->nullable(); + $table->string('style_id')->nullable(); + $table->decimal('quantity', 20, 4)->nullable(); + $table->decimal('unit_price', 20, 4)->nullable(); + $table->string('document_status')->nullable(); + $table->text('labels')->nullable(); + $table->dateTime('document_created_at')->nullable(); + $table->dateTime('finished_at')->nullable(); + $table->dateTime('purchase_updated_at'); + }); + $schema->create('ops_jst_orders', function (Blueprint $table): void { + $table->unsignedBigInteger('Id')->primary(); + $table->unsignedBigInteger('o_id')->index(); + $table->string('so_id')->nullable(); + $table->string('shop_name')->nullable(); + }); + } + + private function seedRows(): void + { + $db = DB::connection('business_mirror'); + $db->table('business_mirror_table_states')->insert([ + 'dataset_key' => 'operations_replica.rpt.jst_Orders', + 'status' => 'succeeded', + 'initial_completed_at' => '2026-09-20 00:00:00', + 'last_succeeded_at' => '2026-09-22 16:40:00', + ]); + $db->table('product_purchase_price_sync_states')->insert([ + 'dataset_key' => ProductPurchasePriceMirrorRepository::DATASET_KEY, + 'status' => 'succeeded', + 'projection_version' => ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION, + 'initial_completed_at' => '2026-09-22 17:30:00', + 'last_succeeded_at' => '2026-09-22 17:35:11', + ]); + $db->table('ops_jst_orders')->insert([ + 'Id' => 1, + 'o_id' => 9416447, + 'so_id' => '110212448119316609', + 'shop_name' => '得物-米客米家家居旗舰店', + ]); + $db->table('product_purchase_price_mirror_rows')->insert([ + [ + 'item_id' => 1, + 'purchase_order_id' => 907329, + 'source_order_no' => 9416447, + 'supplier_name' => '霸州市亚锦五金制品厂', + 'sku_id' => 'DF-C1ZDY000202', + 'quantity' => 1, + 'unit_price' => 19.3, + 'document_status' => 'Confirmed', + 'document_created_at' => '2026-09-22 16:30:03', + 'purchase_updated_at' => '2026-09-22 16:30:04', + ], + [ + 'item_id' => 2, + 'purchase_order_id' => 907330, + 'source_order_no' => 9999999, + 'supplier_name' => '待匹配供应商', + 'sku_id' => 'SKU-FINISHED', + 'quantity' => 2, + 'unit_price' => 8.5, + 'document_status' => 'Finished', + 'document_created_at' => '2026-09-22 17:00:00', + 'purchase_updated_at' => '2026-09-22 17:01:00', + ], + [ + 'item_id' => 3, + 'purchase_order_id' => 896492, + 'source_order_no' => 9416447, + 'supplier_name' => '霸州市亚锦五金制品厂', + 'sku_id' => 'DF-C1ZDY000202', + 'quantity' => 1, + 'unit_price' => 19.3, + 'document_status' => 'Cancelled', + 'document_created_at' => '2026-09-17 16:15:04', + 'purchase_updated_at' => '2026-09-17 16:15:05', + ], + ]); + } +} diff --git a/tests/Unit/EcommerceFinance/ProductPurchasePriceMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/ProductPurchasePriceMirrorSynchronizerTest.php index f6517004..60d022be 100644 --- a/tests/Unit/EcommerceFinance/ProductPurchasePriceMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ProductPurchasePriceMirrorSynchronizerTest.php @@ -29,6 +29,22 @@ class ProductPurchasePriceMirrorSynchronizerTest extends TestCase $this->assertSame('requires_full_sync', $result['status']); } + public function test_incremental_sync_requires_a_fresh_full_pass_for_the_online_daifa_projection(): void + { + $upstream = $this->createMock(ProductPurchasePriceRepository::class); + $upstream->expects($this->never())->method('mirrorBatch'); + $mirror = $this->createMock(ProductPurchasePriceMirrorRepository::class); + $mirror->method('available')->willReturn(true); + $mirror->method('state')->willReturn([ + 'initial_completed_at' => '2026-09-20 04:10:03', + 'projection_version' => 1, + ]); + + $result = (new ProductPurchasePriceMirrorSynchronizer($upstream, $mirror))->sync(); + + $this->assertSame('requires_full_sync', $result['status']); + } + public function test_full_sync_uses_stable_update_time_and_item_id_cursor(): void { Carbon::setTestNow('2026-08-21 12:00:00'); diff --git a/tests/Unit/EcommerceFinance/ProductPurchasePriceRepositoryTest.php b/tests/Unit/EcommerceFinance/ProductPurchasePriceRepositoryTest.php index 5d73d4b9..ca6ed4cd 100644 --- a/tests/Unit/EcommerceFinance/ProductPurchasePriceRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ProductPurchasePriceRepositoryTest.php @@ -44,6 +44,10 @@ class ProductPurchasePriceRepositoryTest extends TestCase $this->assertStringContainsString('[dbo].[jst_purchase] p', $query->sql); $this->assertStringContainsString('[dbo].[jst_purchaseItem] i', $query->sql); $this->assertStringContainsString('i.[po_id] = p.[po_id]', $query->sql); + $this->assertStringContainsString('p.[source_o_id]', $query->sql); + $this->assertStringContainsString('p.[seller] AS supplier_name', $query->sql); + $this->assertStringContainsString('i.[qty] AS quantity', $query->sql); + $this->assertStringContainsString('p.[createTime]', $query->sql); $this->assertStringContainsString('p.[updateTime] >= ?', $query->sql); $this->assertStringContainsString('p.[updateTime] = ? AND i.[Id] > ?', $query->sql); $this->assertStringContainsString('ORDER BY p.[updateTime], i.[Id]', $query->sql); -- 2.47.2