diff --git a/app/Console/Commands/SyncBusinessMirrors.php b/app/Console/Commands/SyncBusinessMirrors.php index 7c3bb48c..e894feff 100644 --- a/app/Console/Commands/SyncBusinessMirrors.php +++ b/app/Console/Commands/SyncBusinessMirrors.php @@ -104,6 +104,30 @@ class SyncBusinessMirrors extends Command Log::error('钉钉应付流程镜像同步失败。', ['exception' => $exception::class]); $this->error('operations_replica.rpt.ding_payable_workflows:同步失败,已保留上一版共享数据。'); } + + // 线上代发业务报销流程复用同一套钉钉流程镜像同步链路,接入此处后 + // 由现有生产 cron 自动断点续传,不额外维护调度脚本。 + try { + $daifaResult = $dingPayableWorkflows + ->forConfigKey('ding_online_daifa_price') + ->sync( + (bool) $this->option('full'), + $this->option('batch') === null ? null : (int) $this->option('batch'), + $this->option('max-batches') === null ? null : (int) $this->option('max-batches'), + ); + if ($daifaResult['status'] !== 'requires_full_sync') { + $this->info(sprintf( + 'operations_replica.rpt.ding_online_daifa_price:%s,本轮 %d 批,累计处理 %d 条明细。', + $daifaResult['status'] === 'succeeded' ? '同步完成' : '同步进行中', + $daifaResult['batches'], + $daifaResult['rows'], + )); + } + } catch (Throwable $exception) { + $failed = true; + Log::error('钉钉线上代发价格流程镜像同步失败。', ['exception' => $exception::class]); + $this->error('operations_replica.rpt.ding_online_daifa_price:同步失败,已保留上一版共享数据。'); + } } return $failed ? self::FAILURE : self::SUCCESS; diff --git a/app/Console/Commands/SyncDingOnlineDaifaPrice.php b/app/Console/Commands/SyncDingOnlineDaifaPrice.php new file mode 100644 index 00000000..b33cc6a0 --- /dev/null +++ b/app/Console/Commands/SyncDingOnlineDaifaPrice.php @@ -0,0 +1,49 @@ +option('full')) { + return self::SUCCESS; + } + + try { + $result = $synchronizer + ->forConfigKey('ding_online_daifa_price') + ->sync( + (bool) $this->option('full'), + $this->option('batch') === null ? null : (int) $this->option('batch'), + $this->option('max-batches') === null ? null : (int) $this->option('max-batches'), + ); + } catch (Throwable $exception) { + Log::error('钉钉线上代发价格流程镜像同步失败。', ['exception' => $exception::class]); + $this->error('钉钉线上代发价格流程同步失败,已保留上一版共享数据。'); + + return self::FAILURE; + } + + $this->info(sprintf( + '%s:本轮 %d 批,累计处理 %d 条明细。', + $result['status'] === 'succeeded' ? '同步完成' : '同步进行中', + $result['batches'], + $result['rows'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 09f0d5ad..c403c1e1 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -35,6 +35,14 @@ class Kernel extends ConsoleKernel $schedule->command('storage-temp:clear')->dailyAt('17:00'); $schedule->command('model:prune')->dailyAt('17:00'); + // 线上代发价格:每天凌晨 00:00 全量/增量同步一次钉钉线上代发业务报销流程。 + // 生产环境由 business-mirror:sync-tables 的 cron 持续增量覆盖,此处保证 + // 一个固定的每日零点刷新入口;命令本身在非刷新环境会安全跳过。 + $schedule->command('business-mirror:sync-ding-online-daifa-price') + ->dailyAt('00:00') + ->withoutOverlapping(60) + ->runInBackground(); + if ((bool) config('services.esign.polling_enabled', false)) { $schedule->command('esign:poll-signatures --quiet') ->cron('*/2 * * * *') diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 0b142a08..712c8e08 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -38,6 +38,7 @@ use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository; use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository; use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository; use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository; +use Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository; use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository; use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; @@ -78,6 +79,7 @@ class Pages extends Controller private readonly CourierFreightReconciliationMirrorRepository $courierFreightReconciliations, private readonly RawBoardPayablesRepository $rawBoardPayables, private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows, + private readonly DingOnlineDaifaPriceMirrorRepository $onlineDaifaPrices, private readonly SupplierPayableReportRepository $supplierPayableReports, private readonly UpstreamQueryCacheFactory $upstreamCaches, private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes, @@ -579,6 +581,7 @@ class Pages extends Controller 'board-deductions' => $this->contentForBoardDeductions(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(), 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), + 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'courier-surcharge' => $this->contentForCourierSurcharge(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), default => null, @@ -737,6 +740,61 @@ class Pages extends Controller } } + protected function contentForOnlineDaifaPrice(): array + { + $clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length); + $date = static function (mixed $value): string { + $value = trim((string) $value); + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : ''; + }; + $status = strtoupper(mb_substr(trim((string) request('status', '')), 0, 30)); + $filters = [ + 'keyword' => $clean(request('keyword', ''), 80), + 'product_id' => $clean(request('product_id', ''), 64), + 'shop_name' => $clean(request('shop_name', ''), 120), + 'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '', + 'created_from' => $date(request('created_from', '')), + 'created_to' => $date(request('created_to', '')), + ]; + $page = max((int) request('page', 1), 1); + $limit = min(max((int) setting('default.list_limit', 25), 1), 100); + + try { + $result = $this->onlineDaifaPrices->paginate($filters, $page, $limit); + $rows = new LengthAwarePaginator( + Collection::make($result['data'] ?? []), + (int) ($result['total'] ?? 0), + $limit, + $page, + ['path' => request()->url(), 'query' => request()->except('page')], + ); + + return [ + 'view' => 'business.pages.product-inventory.online-daifa-price', + 'onlineDaifaRows' => $rows, + 'onlineDaifaFilters' => $filters, + 'onlineDaifaStatus' => $result['status'] ?? 'unknown', + 'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null, + 'onlineDaifaSummary' => $result['summary'] ?? [], + 'onlineDaifaError' => null, + ]; + } catch (Throwable $exception) { + report($exception); + + return [ + 'view' => 'business.pages.product-inventory.online-daifa-price', + 'onlineDaifaRows' => new LengthAwarePaginator([], 0, $limit, $page, [ + 'path' => request()->url(), 'query' => request()->except('page'), + ]), + 'onlineDaifaFilters' => $filters, + 'onlineDaifaStatus' => 'unavailable', + 'onlineDaifaSyncedAt' => null, + 'onlineDaifaSummary' => [], + 'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。', + ]; + } + } + protected function contentForJingxiRefundData(): array { return [ diff --git a/config/business_navigation.php b/config/business_navigation.php index fea78ef6..1f4afbe8 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -295,6 +295,7 @@ return [ 'icon' => 'inventory_2', 'pages' => [ ['slug' => 'normal-products', 'title' => '普通商品资料'], + ['slug' => 'online-daifa-price', 'title' => '线上代发价格'], ], ], [ diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index ad37961a..e286e124 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -14,6 +14,59 @@ return [ 'max_batches_per_run' => (int) env('DING_PAYABLE_WORKFLOW_MIRROR_MAX_BATCHES', 2), ], + // 线上代发业务报销流程(钉钉 OA 审批):数据源为 operations_replica 的 + // rpt.ding_Workflow 中固定流程编码。明细按“商品ID”关联到共享业务镜像的 + // 商品目录(ops_jst_sku_map / ops_jst_sku)做匹配。页面从共享镜像读取, + // 不在 HTTP 请求中直连上游。process_code 由环境变量注入,避免在代码中 + // 硬编码审批模板编码;填入前不会同步任何数据。 + 'ding_online_daifa_price' => [ + 'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price', + 'source_key' => 'operations_replica', + 'process_code' => env('DING_ONLINE_DAIFA_PRICE_PROCESS_CODE', ''), + 'target_table' => 'ding_online_daifa_price_mirror', + 'extend_columns' => true, + // 同步阶段按商品ID(钉钉表单“商品ID”即 shop_i_id)关联商品目录并写入 + // product_name 列,页面只读镜像、不临时 JOIN 2500 万行的 ops_jst_sku_map。 + 'resolve_product_name' => true, + // 钉钉表单字段名 -> 镜像宽表列。下方列名依据用户提供的演示数据校验, + // 若实际审批模板字段名不同,仅需调整此映射即可,无需改动同步与页面代码。 + 'column_map' => [ + 'payee_name' => '收款人全称', + 'product_id' => '商品ID', + 'daifa_trade_no' => '代发交易单号', + 'shop_trade_no' => '店铺交易单号', + 'shop_name' => '店铺名称', + 'supplier_name' => '供应商名称', + 'material_no' => '料号', + 'ship_qty' => '订单发货数量', + 'paid_amount' => '订单实付金额', + 'daifa_amount' => '代发订单金额', + 'price_diff' => '差价', + ], + // 宽表列类型,决定抽取时的强制转换;字符串列留空即可。 + 'column_types' => [ + 'ship_qty' => 'int', + 'paid_amount' => 'decimal', + 'daifa_amount' => 'decimal', + 'price_diff' => 'decimal', + ], + // 若审批模板把“一个商品一行”放在明细表格里,填写该表格的字段名, + // 同步器会按明细行展开为多条镜像记录;留空则整张表单视为单行。 + 'detail_table' => env('DING_ONLINE_DAIFA_PRICE_DETAIL_TABLE', ''), + // 仅保留“收款人全称”为指定公司的报销明细。收款人全称在钉钉表单中是 + // 顶层字段,同步器会把它并入每条明细行;此处按值精确匹配(去首尾空白), + // 不匹配的明细行不写入镜像。默认限定“宁波顾家望业家居有限公司”,切换 + // 收款人只需调整 env,无需改动同步与页面代码;置空该值即不过滤。 + 'row_filters' => [ + 'payee_name' => env('DING_ONLINE_DAIFA_PRICE_PAYEE', '宁波顾家望业家居有限公司'), + ], + // 只同步指定审批状态的单据(默认只要“已完成/已通过”,与页面状态口径一致); + // 未通过/已拒绝/撤回等整单暂不写入镜像。置空则不限状态。 + 'only_status' => env('DING_ONLINE_DAIFA_PRICE_ONLY_STATUS', 'COMPLETED'), + 'batch_size' => (int) env('DING_ONLINE_DAIFA_PRICE_MIRROR_BATCH_SIZE', 100), + 'max_batches_per_run' => (int) env('DING_ONLINE_DAIFA_PRICE_MIRROR_MAX_BATCHES', 2), + ], + // 只有生产刷新节点写镜像;开发环境和网页请求只读共享结果。 'refresh_enabled' => env( 'BUSINESS_MIRROR_REFRESH_ENABLED', diff --git a/database/migrations/2026_09_08_000000_create_ding_online_daifa_price_mirror_tables.php b/database/migrations/2026_09_08_000000_create_ding_online_daifa_price_mirror_tables.php new file mode 100644 index 00000000..81deeafa --- /dev/null +++ b/database/migrations/2026_09_08_000000_create_ding_online_daifa_price_mirror_tables.php @@ -0,0 +1,61 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('ding_online_daifa_price_mirror')) { + $schema->create('ding_online_daifa_price_mirror', function (Blueprint $table): void { + $table->string('process_instance_id', 100); + $table->unsignedTinyInteger('detail_index')->default(0); + // 宽表列:按钉钉表单字段抽取,建立索引供页面筛选与按商品ID关联。 + $table->string('product_id', 64)->nullable()->index(); + $table->string('daifa_trade_no', 64)->nullable()->index(); + $table->string('shop_trade_no', 64)->nullable()->index(); + $table->string('shop_name', 120)->nullable()->index(); + $table->string('payee_name', 120)->nullable()->index(); + $table->string('supplier_name', 120)->nullable(); + $table->string('material_no', 120)->nullable(); + $table->unsignedInteger('ship_qty')->nullable(); + $table->decimal('paid_amount', 14, 2)->nullable(); + $table->decimal('daifa_amount', 14, 2)->nullable(); + $table->decimal('price_diff', 14, 2)->nullable(); + // 原始载荷保留,页面可展开核对原始表单,避免因模板改版丢数据。 + $table->json('workflow_payload'); + $table->json('scheme_payload'); + $table->json('task_payload')->nullable(); + $table->string('workflow_created_at', 32)->nullable()->index(); + $table->uuid('sync_generation')->nullable()->index(); + $table->dateTime('synced_at', 6); + + $table->primary(['process_instance_id', 'detail_index']); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + Schema::connection($this->connection())->dropIfExists('ding_online_daifa_price_mirror'); + } +}; diff --git a/database/migrations/2026_09_08_000100_add_daifa_product_name_and_skumap_index.php b/database/migrations/2026_09_08_000100_add_daifa_product_name_and_skumap_index.php new file mode 100644 index 00000000..0127519b --- /dev/null +++ b/database/migrations/2026_09_08_000100_add_daifa_product_name_and_skumap_index.php @@ -0,0 +1,80 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->mirrorConnection()); + + // 1) 线上代发价格镜像宽表增加“已匹配商品名称”列。 + // 商品名称在同步阶段按商品ID关联商品目录后写入,页面只读镜像, + // 不在此处临时 JOIN 2500 万行的 ops_jst_sku_map,避免页面查询超时。 + if ($schema->hasTable('ding_online_daifa_price_mirror') + && ! $schema->hasColumn('ding_online_daifa_price_mirror', 'product_name')) { + $schema->table('ding_online_daifa_price_mirror', function (Blueprint $table): void { + $table->string('product_name', 255)->nullable()->comment('按商品ID关联商品目录得到的名称'); + }); + } + + // 2) ops_jst_sku_map 是 2500 万行的共享镜像,原来没有 shop_i_id 单列索引, + // 按商品ID(钉钉表单“商品ID”即 shop_i_id)匹配只能全表扫描。这里补建 + // 单列索引,使按商品ID的关联在同步阶段可秒级完成。 + $this->ensureShopIIdIndex(); + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->mirrorConnection()); + + if ($schema->hasTable('ding_online_daifa_price_mirror') + && $schema->hasColumn('ding_online_daifa_price_mirror', 'product_name')) { + $schema->table('ding_online_daifa_price_mirror', function (Blueprint $table): void { + $table->dropColumn('product_name'); + }); + } + + if ($this->hasIndex('ops_jst_sku_map', 'idx_shop_i_id')) { + $schema->table('ops_jst_sku_map', function (Blueprint $table): void { + $table->dropIndex('idx_shop_i_id'); + }); + } + } + + private function hasIndex(string $table, string $index): bool + { + $rows = DB::connection($this->mirrorConnection()) + ->select("SELECT 1 FROM information_schema.statistics WHERE TABLE_SCHEMA='caiwu_business_mirror' AND TABLE_NAME=? AND INDEX_NAME=? LIMIT 1", [$table, $index]); + + return count($rows) > 0; + } + + private function ensureShopIIdIndex(): void + { + if (! $this->hasIndex('ops_jst_sku_map', 'idx_shop_i_id')) { + Schema::connection($this->mirrorConnection()) + ->table('ops_jst_sku_map', function (Blueprint $table): void { + $table->index('shop_i_id', 'idx_shop_i_id'); + }); + } + } +}; diff --git a/database/migrations/2026_09_08_000200_add_daifa_payee_name.php b/database/migrations/2026_09_08_000200_add_daifa_payee_name.php new file mode 100644 index 00000000..0cb4dac7 --- /dev/null +++ b/database/migrations/2026_09_08_000200_add_daifa_payee_name.php @@ -0,0 +1,50 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + // 线上代发价格镜像宽表增加“收款人全称”列。同步阶段按钉钉表单“收款人全称” + // 抽取该列,并据此把镜像限定到指定收款人(默认宁波顾家望业家居有限公司)。 + // 已建表但缺列时补列;建表迁移已含该列则跳过,保证幂等。 + if ($schema->hasTable('ding_online_daifa_price_mirror') + && ! $schema->hasColumn('ding_online_daifa_price_mirror', 'payee_name')) { + $schema->table('ding_online_daifa_price_mirror', function (Blueprint $table): void { + $table->string('payee_name', 120)->nullable()->index()->after('shop_name'); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if ($schema->hasTable('ding_online_daifa_price_mirror') + && $schema->hasColumn('ding_online_daifa_price_mirror', 'payee_name')) { + $schema->table('ding_online_daifa_price_mirror', function (Blueprint $table): void { + $table->dropColumn('payee_name'); + }); + } + } +}; diff --git a/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php new file mode 100644 index 00000000..301c24b8 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php @@ -0,0 +1,176 @@ +connection = $mirror->connection(); + } + + 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 array{data:list>,total:int,status:string,last_synced_at:?string,summary:array} */ + public function paginate(array $filters, int $page, int $perPage): array + { + if (! $this->ready()) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $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(); + + $query = $this->connection->table(self::TARGET_TABLE); + + if (($filters['product_id'] ?? '') !== '') { + $query->where('product_id', (string) $filters['product_id']); + } + if (($filters['shop_name'] ?? '') !== '') { + $query->where('shop_name', (string) $filters['shop_name']); + } + if (($filters['keyword'] ?? '') !== '') { + $keyword = (string) $filters['keyword']; + $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 . '%'); + }); + } + 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')); + } + + $rows = $query->orderByDesc('workflow_created_at')->orderByDesc('process_instance_id')->orderByDesc('detail_index')->get(); + + $statusFilter = strtoupper(trim((string) ($filters['status'] ?? ''))); + $matched = []; + foreach ($rows as $row) { + $workflow = $this->decode((string) $row->workflow_payload, []); + if ($statusFilter !== '' && DingPayableWorkflowStatus::key($workflow) !== $statusFilter) { + continue; + } + $matched[] = $row; + } + + $total = count($matched); + $pageRows = array_slice($matched, ($page - 1) * $perPage, $perPage); + + $data = []; + foreach ($pageRows as $row) { + $workflow = $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 + ? ['matched' => true, 'name' => $resolvedName, 'sku_id' => null, 'i_id' => null] + : ['matched' => false, 'name' => null, 'sku_id' => null, 'i_id' => null]; + $data[] = [ + 'process_instance_id' => (string) $row->process_instance_id, + 'detail_index' => (int) $row->detail_index, + '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 ?? ''), + 'supplier_name' => (string) ($row->supplier_name ?? ''), + 'material_no' => (string) ($row->material_no ?? ''), + 'ship_qty' => $row->ship_qty === null ? null : (int) $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), + 'matched_product' => $product, + 'workflow_created_at' => (string) ($row->workflow_created_at ?? ''), + ]; + } + + return [ + 'data' => $data, + 'total' => $total, + 'status' => (string) ($state->status ?? 'unknown'), + 'last_synced_at' => $state->last_succeeded_at ? (string) $state->last_succeeded_at : null, + 'summary' => $this->summary($matched), + ]; + } + + /** @param list $rows */ + private function summary(array $rows): array + { + $daifa = 0.0; + $paid = 0.0; + $diff = 0.0; + $matched = 0; + foreach ($rows as $row) { + $daifa += (float) ($row->daifa_amount ?? 0); + $paid += (float) ($row->paid_amount ?? 0); + $diff += (float) ($row->price_diff ?? 0); + if (! empty($row->product_id) && $this->cleanName($row->product_name ?? null) !== null) { + $matched++; + } + } + return [ + 'count' => count($rows), + 'matched_count' => $matched, + 'daifa_amount' => round($daifa, 2), + 'paid_amount' => round($paid, 2), + 'price_diff' => round($diff, 2), + ]; + } + + private function cleanName(mixed $value): ?string + { + if ($value === null) { + return null; + } + $name = trim((string) $value); + return $name === '' ? null : $name; + } + + private function decode(string $json, mixed $fallback): mixed + { + if ($json === '') { + return $fallback; + } + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : $fallback; + } +} diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php index 7f011d71..79ceec29 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php @@ -23,7 +23,7 @@ class BusinessMirrorSchemaManager $table->char('_mirror_key', 64)->primary(); foreach ($columns as $column) { - $this->addColumn($table, $column); + $this->addColumn($table, $column, $dataset); } $table->uuid('_mirror_generation')->nullable()->index(); @@ -49,9 +49,9 @@ class BusinessMirrorSchemaManager )); if ($missing !== []) { - $schema->table($dataset->targetTable(), function (Blueprint $table) use ($missing): void { + $schema->table($dataset->targetTable(), function (Blueprint $table) use ($missing, $dataset): void { foreach ($missing as $column) { - $this->addColumn($table, $column); + $this->addColumn($table, $column, $dataset); } }); } @@ -120,7 +120,7 @@ class BusinessMirrorSchemaManager return $rows; } - private function addColumn(Blueprint $table, array $column): void + private function addColumn(Blueprint $table, array $column, BusinessMirrorDataset $dataset): void { $name = (string) $column['column_name']; $type = strtolower((string) $column['data_type']); @@ -144,9 +144,11 @@ class BusinessMirrorSchemaManager // MariaDB counts every inline varchar column towards the 65,535-byte // row limit. Wide legacy SQL Server tables can contain dozens of // nvarchar columns even though each individual column looks small. - // Keep only genuinely short values inline; TEXT is stored off-page - // and lets the mirror preserve the complete source schema. - 'char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying' => $length > 0 && $length <= 64 + // Keep only genuinely short values inline. A declared lookup index + // may opt a bounded legacy identifier into VARCHAR; every other + // wide string remains TEXT and is stored off-page. + 'char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying' => $length > 0 + && ($length <= 64 || ($length <= 191 && $this->isConfiguredIndexColumn($dataset, $name))) ? $table->string($name, $length) : $table->longText($name), default => $table->longText($name), @@ -155,7 +157,7 @@ class BusinessMirrorSchemaManager $definition->nullable(); } - private function isIndexable(array $columns, string $name): bool + private function isIndexable(array $columns, string $name, ?BusinessMirrorDataset $dataset = null): bool { foreach ($columns as $column) { if (strcasecmp((string) $column['column_name'], $name) !== 0) { @@ -166,7 +168,8 @@ class BusinessMirrorSchemaManager $length = (int) ($column['max_length'] ?? 0); return ! in_array($type, ['text', 'ntext', 'xml', 'image', 'binary', 'varbinary', 'bytea', 'json', 'jsonb', 'array', 'user-defined'], true) - && (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying'], true) || ($length > 0 && $length <= 64)); + && (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying'], true) + || ($length > 0 && ($length <= 64 || ($length <= 191 && $dataset !== null && $this->isConfiguredIndexColumn($dataset, $name))))); } return false; @@ -188,7 +191,7 @@ class BusinessMirrorSchemaManager foreach ($dataset->indexDefinitions() as $index) { foreach ($index as $column) { if (! in_array(strtolower($column), $columnNames, true) - || ! $this->isIndexable($columns, $column)) { + || ! $this->isIndexable($columns, $column, $dataset)) { throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key()); } } @@ -197,6 +200,19 @@ class BusinessMirrorSchemaManager return $dataset->indexDefinitions(); } + private function isConfiguredIndexColumn(BusinessMirrorDataset $dataset, string $column): bool + { + foreach ($dataset->indexDefinitions() as $index) { + foreach ($index as $indexedColumn) { + if (strcasecmp((string) $indexedColumn, $column) === 0) { + return true; + } + } + } + + return false; + } + private function configuredIndexName(string $table, array $columns, int $position): string { $signature = implode('_', $columns) . '_' . $position; diff --git a/modules/EcommerceFinance/Services/DingPayableWorkflowMirrorSynchronizer.php b/modules/EcommerceFinance/Services/DingPayableWorkflowMirrorSynchronizer.php index 167b2894..233e88de 100644 --- a/modules/EcommerceFinance/Services/DingPayableWorkflowMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/DingPayableWorkflowMirrorSynchronizer.php @@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Services; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\Builder; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Carbon; use Illuminate\Support\Str; @@ -11,15 +12,23 @@ use RuntimeException; use Throwable; /** - * 将钉钉应付申请流程的固定流程编码同步为按流程聚合的共享镜像。 + * 将钉钉审批流程(按固定流程编码)同步为按流程聚合的共享镜像。 * * 钉钉表单字段存放在 JSON 中,且历史版本字段不完全一致;镜像保留原始 * 主表、表单和审批任务 payload,页面只在镜像库中解析和筛选。 + * + * 同一套同步逻辑通过 configKey 支持多个钉钉流程:默认处理“应付申请流程”, + * 配置 ding_online_daifa_price 时按列映射抽取宽表列并(可选)按明细表格 + * 展开为多行,便于按商品ID关联商品目录。新增流程只需在 + * config/upstream_mirrors.php 增加一项,无需另造同步链路。 */ class DingPayableWorkflowMirrorSynchronizer { private readonly ConnectionInterface $connection; + /** 当前同步的钉钉流程配置键,默认“应付申请流程”。 */ + private string $configKey = 'ding_payable_workflows'; + public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries) { $this->connection = app('db')->connection( @@ -27,10 +36,18 @@ class DingPayableWorkflowMirrorSynchronizer ); } + /** 切换要同步的钉钉流程配置键(复用同一套同步链路)。 */ + public function forConfigKey(string $configKey): self + { + $this->configKey = $configKey; + + return $this; + } + /** @return array{status:string,mode:string,batches:int,rows:int,dataset:string} */ public function sync(bool $full = false, ?int $batchSize = null, ?int $maxBatches = null): array { - $config = (array) config('upstream_mirrors.ding_payable_workflows', []); + $config = (array) config('upstream_mirrors.' . $this->configKey, []); $dataset = (string) ($config['dataset_key'] ?? 'operations_replica.rpt.ding_payable_workflows'); $target = (string) ($config['target_table'] ?? 'ding_payable_workflow_mirror'); $source = (string) ($config['source_key'] ?? 'operations_replica'); @@ -92,12 +109,24 @@ class DingPayableWorkflowMirrorSynchronizer $taskById = $this->groupBy($tasks, 'processInstanceId'); $now = now(); $payloads = []; + $extendColumns = ! empty($config['extend_columns']); + $columnMap = $extendColumns ? (array) ($config['column_map'] ?? []) : []; + $columnTypes = $extendColumns ? (array) ($config['column_types'] ?? []) : []; + $detailTable = $extendColumns ? trim((string) ($config['detail_table'] ?? '')) : ''; + $resolveProductName = $extendColumns && ! empty($config['resolve_product_name']); + $rowFilters = $extendColumns ? (array) ($config['row_filters'] ?? []) : []; + $onlyStatus = strtoupper(trim((string) ($config['only_status'] ?? ''))); foreach ($workflows as $workflow) { $id = (string) ($workflow['processInstanceId'] ?? ''); if ($id === '') { continue; } + // 工作流级状态过滤:只保留指定审批状态(默认只要“已完成/已通过”), + // 未通过/已拒绝/撤回等整单不写入镜像。置空 only_status 则不限状态。 + if ($onlyStatus !== '' && DingPayableWorkflowStatus::key($workflow) !== $onlyStatus) { + continue; + } $originatorId = trim((string) ($workflow['originatorUserId'] ?? '')); $originatorName = trim((string) ($originatorNames[$originatorId] ?? '')); if ($originatorName !== '') { @@ -105,15 +134,38 @@ class DingPayableWorkflowMirrorSynchronizer // 页面无需在请求期间再次直连上游或猜测标题内容。 $workflow['originatorUserName'] = $originatorName; } - $payloads[] = [ - 'process_instance_id' => $id, - 'workflow_payload' => json_encode($workflow, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), - 'scheme_payload' => json_encode($schemeById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), - 'task_payload' => json_encode($taskById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), - 'workflow_created_at' => isset($workflow['createAt']) ? (string) $workflow['createAt'] : null, - 'sync_generation' => $generation, - 'synced_at' => $now, - ]; + $schemeRows = $schemeById[$id] ?? []; + $detailFieldsList = $this->buildDetailRowFields($schemeRows, $detailTable); + $detailIndex = 0; + foreach ($detailFieldsList as $fields) { + $payload = [ + 'process_instance_id' => $id, + 'workflow_payload' => json_encode($workflow, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + 'scheme_payload' => json_encode($schemeRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + 'task_payload' => json_encode($taskById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + 'workflow_created_at' => isset($workflow['createAt']) ? (string) $workflow['createAt'] : null, + 'sync_generation' => $generation, + 'synced_at' => $now, + ]; + if ($extendColumns) { + foreach ($columnMap as $column => $label) { + $payload[$column] = $this->castColumn($fields[$label] ?? null, (string) ($columnTypes[$column] ?? 'string')); + } + // 行级过滤(如“收款人全称”限定):不匹配的明细行直接跳过, + // 不写入镜像,也不占用 detail_index,保证保留行编号连续。 + if ($rowFilters !== [] && ! $this->matchesRowFilters($payload, $rowFilters)) { + continue; + } + $payload['detail_index'] = $detailIndex; + } + $payloads[] = $payload; + $detailIndex++; + } + + } + + if ($resolveProductName && $payloads !== []) { + $this->resolveProductNames($payloads); } $nextCursor = $this->cursorFromWorkflow($workflows[array_key_last($workflows)]); @@ -123,12 +175,25 @@ class DingPayableWorkflowMirrorSynchronizer $cursor = $nextCursor; $rows += count($payloads); $batches++; - $this->connection->transaction(function () use ($dataset, $target, $payloads, $cursor, $generation, $rows, $now): void { + $upsertKeys = $extendColumns ? ['process_instance_id', 'detail_index'] : ['process_instance_id']; + $updateColumns = ['workflow_payload', 'scheme_payload', 'task_payload', 'workflow_created_at', 'sync_generation', 'synced_at']; + if ($extendColumns) { + $updateColumns = array_merge($updateColumns, array_keys($columnMap)); + if ($resolveProductName) { + $updateColumns[] = 'product_name'; + } + } + $this->connection->transaction(function () use ($dataset, $target, $payloads, $cursor, $generation, $rows, $now, $upsertKeys, $updateColumns, $extendColumns, $ids): void { + // A changed workflow may have fewer details or no longer match + // the approved status/payee. Replace its rows atomically. + if ($extendColumns && $ids !== []) { + $this->connection->table($target)->whereIn('process_instance_id', $ids)->delete(); + } if ($payloads !== []) { $this->connection->table($target)->upsert( $payloads, - ['process_instance_id'], - ['workflow_payload', 'scheme_payload', 'task_payload', 'workflow_created_at', 'sync_generation', 'synced_at'], + $upsertKeys, + $updateColumns, ); } $this->connection->table('business_mirror_table_states') @@ -166,6 +231,12 @@ class DingPayableWorkflowMirrorSynchronizer if ($schema->hasTable($target)) { return; } + $config = (array) config('upstream_mirrors.' . $this->configKey, []); + if (! empty($config['extend_columns'])) { + $this->createExtendedMirrorTable($schema, $target); + + return; + } $schema->create($target, function (Blueprint $table): void { $table->string('process_instance_id', 100)->primary(); $table->json('workflow_payload'); @@ -177,6 +248,37 @@ class DingPayableWorkflowMirrorSynchronizer }); } + /** + * 扩展镜像表:在原始 JSON 载荷之外抽取宽表列并建立索引,便于按商品ID + * 关联商品目录、按单据号/店铺筛选。结构与对应迁移保持一致。 + */ + private function createExtendedMirrorTable(Builder $schema, string $target): void + { + $schema->create($target, function (Blueprint $table): void { + $table->string('process_instance_id', 100); + $table->unsignedTinyInteger('detail_index')->default(0); + $table->string('product_id', 64)->nullable()->index(); + $table->string('daifa_trade_no', 64)->nullable()->index(); + $table->string('shop_trade_no', 64)->nullable()->index(); + $table->string('shop_name', 120)->nullable()->index(); + $table->string('payee_name', 120)->nullable()->index(); + $table->string('supplier_name', 120)->nullable(); + $table->string('material_no', 120)->nullable(); + $table->string('product_name', 255)->nullable()->comment('按商品ID关联商品目录得到的名称'); + $table->unsignedInteger('ship_qty')->nullable(); + $table->decimal('paid_amount', 14, 2)->nullable(); + $table->decimal('daifa_amount', 14, 2)->nullable(); + $table->decimal('price_diff', 14, 2)->nullable(); + $table->json('workflow_payload'); + $table->json('scheme_payload'); + $table->json('task_payload')->nullable(); + $table->string('workflow_created_at', 32)->nullable()->index(); + $table->uuid('sync_generation')->nullable()->index(); + $table->dateTime('synced_at', 6); + $table->primary(['process_instance_id', 'detail_index']); + }); + } + /** @return array|null */ private function state(string $dataset): ?array { @@ -438,4 +540,222 @@ class DingPayableWorkflowMirrorSynchronizer } return $grouped; } + + /** + * 从表单 scheme 解析每一行的“字段名 -> 值”映射。 + * + * 若配置了明细表格字段名,则按该表格的每一行展开(并合并顶层字段, + * 如店铺名称),否则整张表单视为单行。返回数组长度即镜像明细行数。 + * + * @param array> $schemeRows + * @return list> + */ + private function buildDetailRowFields(array $schemeRows, string $detailTable): array + { + $topLevel = $this->formFieldLookup($schemeRows); + if ($detailTable === '') { + return [$topLevel]; + } + + $grid = null; + foreach ($schemeRows as $row) { + if (! is_array($row)) { + continue; + } + if (trim((string) ($row['name'] ?? '')) === $detailTable) { + $value = $row['value'] ?? null; + // 钉钉明细表格在上游/镜像中以 JSON 字符串存储,需先解码为数组再按行展开。 + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : null; + } + $grid = is_array($value) ? $value : null; + break; + } + } + + if ($grid === null) { + return [$topLevel]; + } + + $rows = []; + foreach ($grid as $gridRow) { + if (! is_array($gridRow) || ! isset($gridRow['rowValue']) || ! is_array($gridRow['rowValue'])) { + continue; + } + $cellLookup = []; + foreach ($gridRow['rowValue'] as $cell) { + if (! is_array($cell) || ! isset($cell['label'])) { + continue; + } + $cellLookup[(string) $cell['label']] = $this->scalarValue($cell['value'] ?? null); + } + $rows[] = array_merge($topLevel, $cellLookup); + } + + return $rows === [] ? [$topLevel] : $rows; + } + + /** @param array> $schemeRows @return array */ + private function formFieldLookup(array $schemeRows): array + { + $lookup = []; + foreach ($schemeRows as $row) { + if (! is_array($row)) { + continue; + } + $name = trim((string) ($row['name'] ?? '')); + if ($name === '') { + continue; + } + $lookup[$name] = $this->scalarValue($row['value'] ?? null); + } + return $lookup; + } + + private function scalarValue(mixed $value): mixed + { + if (is_scalar($value) || $value === null) { + return $value; + } + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * 行级过滤:按“镜像列 => 目标值”精确匹配(去首尾空白)。任一条件不满足即 + * 返回 false,该明细行不写入镜像。目标值为空表示不对该列过滤;过滤集合为 + * 空则全部通过。用于把“线上代发业务报销”限定到指定收款人全称等场景。 + * + * @param array $payload + * @param array $filters + */ + private function matchesRowFilters(array $payload, array $filters): bool + { + foreach ($filters as $column => $expected) { + $expected = trim((string) $expected); + if ($expected === '') { + continue; + } + if (trim((string) ($payload[$column] ?? '')) !== $expected) { + return false; + } + } + + return true; + } + + private function castColumn(mixed $value, string $type): mixed + { + if ($value === null || $value === '') { + return null; + } + return match ($type) { + 'int' => is_numeric($value) ? (int) $value : null, + 'decimal' => is_numeric($value) ? (float) $value : null, + default => is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + }; + } + + /** + * 同步阶段按商品ID(钉钉表单“商品ID”即 ops_jst_sku_map.shop_i_id)关联商品目录, + * 将已匹配的商品名称写入镜像宽表的 product_name 列。页面只读镜像,不在此处临时 + * JOIN 2500 万行的商品映射表,避免页面查询超时。所有商品ID一次性批量关联, + * 未命中的再回退到 ops_jst_sku 的 i_id/sku_id。 + * + * @param list> $payloads + */ + private function resolveProductNames(array &$payloads): void + { + $ids = []; + foreach ($payloads as $payload) { + $id = trim((string) ($payload['product_id'] ?? '')); + if ($id !== '' && ! isset($ids[$id])) { + $ids[$id] = true; + } + } + if ($ids === []) { + return; + } + $idList = array_keys($ids); + + $names = []; + if ($this->connection->getSchemaBuilder()->hasTable('ops_jst_sku_map')) { + $rows = $this->guardedQuery(4000, fn () => $this->connection->table('ops_jst_sku_map') + ->whereIn('shop_i_id', $idList) + ->select(['shop_i_id', 'sku_id', 'i_id', 'name']) + ->limit(5000) + ->get()); + foreach ($rows as $row) { + $id = (string) $row->shop_i_id; + if (! isset($names[$id])) { + $name = $this->cleanProductName($row->name ?? null); + if ($name !== null) { + $names[$id] = $name; + } + } + } + } + + $missing = array_values(array_filter($idList, static fn (string $id): bool => ! isset($names[$id]))); + if ($missing !== [] && $this->connection->getSchemaBuilder()->hasTable('ops_jst_sku')) { + $rows = $this->guardedQuery(4000, fn () => $this->connection->table('ops_jst_sku') + ->whereIn('i_id', $missing) + ->orWhereIn('sku_id', $missing) + ->select(['i_id', 'sku_id', 'name']) + ->limit(5000) + ->get()); + foreach ($rows as $row) { + foreach ([(string) $row->i_id, (string) $row->sku_id] as $candidate) { + if ($candidate !== '' && isset($names[$candidate]) === false) { + $name = $this->cleanProductName($row->name ?? null); + if ($name !== null) { + $names[$candidate] = $name; + } + } + } + } + } + + foreach ($payloads as &$payload) { + $id = trim((string) ($payload['product_id'] ?? '')); + $payload['product_name'] = $id !== '' && isset($names[$id]) ? $names[$id] : null; + } + unset($payload); + } + + private function cleanProductName(mixed $value): ?string + { + if ($value === null) { + return null; + } + $name = trim((string) $value); + return $name === '' ? null : $name; + } + + /** + * 对可能命中大表的查询加会话级超时,避免索引缺失时整批同步被拖垮。 + * 超时后该批商品名称留空,页面仍可正常展示(仅缺失匹配名),不影响同步完成。 + * + * @template T + * @param callable():T $callback + * @return T + */ + private function guardedQuery(int $milliseconds, callable $callback) + { + $conn = $this->connection; + try { + $conn->statement('SET SESSION MAX_EXECUTION_TIME=' . $milliseconds); + return $callback(); + } catch (Throwable $exception) { + // 超时(SQLSTATE HY000/3024)或其它只读异常:放弃匹配该批商品名称, + // 不让同步链路中断。 + return []; + } finally { + try { + $conn->statement('SET SESSION MAX_EXECUTION_TIME=0'); + } catch (Throwable) { + // 忽略重置失败。 + } + } + } } 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 new file mode 100644 index 00000000..d7abafdc --- /dev/null +++ b/resources/views/business/pages/product-inventory/online-daifa-price.blade.php @@ -0,0 +1,157 @@ +@php + $rows = $onlineDaifaRows ?? null; + $filters = $onlineDaifaFilters ?? []; + $status = (string) ($onlineDaifaStatus ?? 'unknown'); + $syncedAt = $onlineDaifaSyncedAt ?? null; + $summary = $onlineDaifaSummary ?? []; + $error = $onlineDaifaError ?? null; + $json = static fn (mixed $value): string => is_array($value) + ? (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '-') + : (string) ($value ?? '-'); + $statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions(); + $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'] ?? '-') + : '(未匹配)'; + $payeeScope = trim((string) config('upstream_mirrors.ding_online_daifa_price.row_filters.payee_name', '')); +@endphp + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ + 重置 +
+
+ + @if ($error) + + @endif + +
+ 固定流程:线上代发业务报销流程(钉钉 OA 审批) + @if ($payeeScope !== '') 固定收款人:{{ $payeeScope }} @endif + 镜像状态:{{ $status }} + @if ($syncedAt) 最近同步:{{ $syncedAt }} @endif + 共 {{ $rows?->total() ?? 0 }} 条 +
+ +
+
代发订单金额合计{{ $money($summary['daifa_amount'] ?? null) }}
+
订单实付金额合计{{ $money($summary['paid_amount'] ?? null) }}
+
差价合计{{ $money($summary['price_diff'] ?? null) }}
+
已匹配商品目录{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['count'] ?? 0) }}
+
+ +
+ + + + + + + + + + @forelse ($rows ?? [] as $row) + @php($workflow = $row['workflow'] ?? []) + @php($approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? '-')) + @php($originator = trim((string) ($workflow['originatorUserName'] ?? '')) ?: trim((string) ($workflow['originatorUserId'] ?? '-'))) + + + + + + + + + + + + + + + + + + + + + @empty + + @endforelse + +
审批编号标题状态申请人收款人全称商品ID商品名称(匹配)店铺名称代发交易单号店铺交易单号发货数量订单实付金额代发订单金额差价供应商名称料号创建时间原始表单
{{ $approvalNumber }}{{ $workflow['title'] ?? '-' }}{{ $row['status_label'] ?? '-' }}{{ $originator }}{{ $num($row['payee_name']) }}{{ $num($row['product_id']) }}{{ $productName($row) }}{{ $num($row['shop_name']) }}{{ $num($row['daifa_trade_no']) }}{{ $num($row['shop_trade_no']) }}{{ $num($row['ship_qty']) }}{{ $money($row['paid_amount']) }}{{ $money($row['daifa_amount']) }}{{ $money($row['price_diff']) }}{{ $num($row['supplier_name']) }}{{ $num($row['material_no']) }}{{ $row['workflow_created_at'] ?? '-' }}
展开表单({{ count($row['scheme'] ?? []) }} 项)
{{ $json($row['scheme'] ?? []) }}
{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的线上代发价格数据;同步完成后会自动显示。' }}
+
+ 当前页 {{ $rows?->count() ?? 0 }} 条 · 第 {{ $rows?->currentPage() ?? 1 }} 页 + + @if ($rows && !$rows->onFirstPage())上一页@endif + @if ($rows && $rows->hasMorePages())下一页@endif + +
+
+
diff --git a/tests/Feature/Business/OnlineDaifaPricePageTest.php b/tests/Feature/Business/OnlineDaifaPricePageTest.php new file mode 100644 index 00000000..ff5e0979 --- /dev/null +++ b/tests/Feature/Business/OnlineDaifaPricePageTest.php @@ -0,0 +1,131 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $repository = Mockery::mock(DingOnlineDaifaPriceMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->andReturn([ + 'data' => [[ + 'process_instance_id' => 'DEMO-6927049845766979458', + 'detail_index' => 0, + 'product_id' => '3821829253650055444', + 'daifa_trade_no' => '6927049845766979458', + 'shop_trade_no' => '6927049845766979458', + 'shop_name' => '抖音-HX旗舰店101', + 'payee_name' => '宁波顾家望业家居有限公司', + 'supplier_name' => '', + 'material_no' => '', + 'ship_qty' => 1, + 'paid_amount' => 616.61, + 'daifa_amount' => 375.0, + 'price_diff' => 241.61, + 'workflow' => [ + 'businessId' => '20260901001', + 'title' => '线上代发业务报销', + 'status' => 'COMPLETED', + 'result' => 'agree', + 'originatorUserName' => '扬灵', + 'createAt' => '2026-09-01 10:00:00', + ], + 'scheme' => [['name' => '商品ID', 'value' => '3821829253650055444']], + 'tasks' => [], + 'status_key' => 'COMPLETED', + 'status_label' => '已完成', + 'matched_product' => ['matched' => true, 'name' => '演示商品', 'sku_id' => null, 'i_id' => null], + 'workflow_created_at' => '2026-09-01 10:00:00', + ]], + 'total' => 1, + 'status' => 'succeeded', + 'last_synced_at' => '2026-09-08 00:00:05', + 'summary' => [ + 'count' => 1, + 'matched_count' => 1, + 'daifa_amount' => 375.0, + 'paid_amount' => 616.61, + 'price_diff' => 241.61, + ], + ]); + $this->app->instance(DingOnlineDaifaPriceMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'product-inventory', + 'page' => 'online-daifa-price', + ]))->assertOk() + ->assertSee('线上代发价格') + ->assertSee('固定流程:线上代发业务报销流程(钉钉 OA 审批)') + ->assertSee('固定收款人:') + ->assertSee('宁波顾家望业家居有限公司') + ->assertSee('镜像状态:succeeded') + ->assertSee('3821829253650055444') + ->assertSee('演示商品') + ->assertSee('抖音-HX旗舰店101') + ->assertSee('6927049845766979458') + ->assertSee('已完成') + ->assertSee('扬灵') + ->assertSee('已匹配商品目录') + ->assertSee('展开表单(1 项)'); + } + + public function test_online_daifa_price_page_keeps_an_unavailable_mirror_readable(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $repository = Mockery::mock(DingOnlineDaifaPriceMirrorRepository::class); + $repository->shouldReceive('paginate')->andThrow(new \RuntimeException('not ready')); + $this->app->instance(DingOnlineDaifaPriceMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'product-inventory', + 'page' => 'online-daifa-price', + ]))->assertOk() + ->assertSee('线上代发价格镜像尚未完成同步,请稍后重试。') + ->assertSee('镜像读取失败,请稍后重试。'); + } + + public function test_online_daifa_price_page_passes_filters_to_repository(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $repository = Mockery::mock(DingOnlineDaifaPriceMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->with( + [ + 'keyword' => '6927049845766979458', + 'product_id' => '3821829253650055444', + 'shop_name' => '抖音-HX旗舰店101', + 'status' => '', + 'created_from' => '2026-09-01', + 'created_to' => '2026-09-07', + ], + 1, + Mockery::type('int'), + )->andReturn([ + 'data' => [], + 'total' => 0, + 'status' => 'succeeded', + 'last_synced_at' => '2026-09-08 00:00:05', + 'summary' => [], + ]); + $this->app->instance(DingOnlineDaifaPriceMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'product-inventory', + 'page' => 'online-daifa-price', + 'keyword' => '6927049845766979458', + 'product_id' => '3821829253650055444', + 'shop_name' => '抖音-HX旗舰店101', + 'created_from' => '2026-09-01', + 'created_to' => '2026-09-07', + ]))->assertOk() + ->assertSee('商品ID') + ->assertSee('店铺名称') + ->assertSee('2026-09-01') + ->assertSee('2026-09-07'); + } +} diff --git a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php index a601244b..24cc92ec 100644 --- a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php @@ -246,6 +246,77 @@ class BusinessMirrorSynchronizerTest extends TestCase $this->assertSame('text', $types['longLabel']); } + public function test_small_payment_work_order_dataset_accepts_only_supported_indexes(): void + { + $datasets = (array) config('upstream_mirrors.datasets', []); + $datasets['jiansun.dbo.jst_workOrderInfo'] = [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'jst_workOrderInfo', + 'target' => 'test_small_payment_work_orders', + 'keys' => ['Id'], + 'watermark' => 'updateTime', + 'columns' => [ + 'Id', 'woNo', 'woTypeId', 'title', 'content', 'status', 'grade', + 'urging', 'createby', 'executors', 'beginTime', 'endTime', + 'completer', 'completeTime', 'updated', 'created', + 'businessFieldList', 'createTime', 'updateTime', + ], + 'indexes' => [ + ['woTypeId', 'woNo', 'Id'], + ['created', 'Id'], + ], + ]; + config(['upstream_mirrors.datasets' => $datasets]); + + $columns = [ + ['name' => 'Id', 'type' => 'int', 'length' => null], + ['name' => 'woNo', 'type' => 'nvarchar', 'length' => 100], + ['name' => 'woTypeId', 'type' => 'nvarchar', 'length' => 100], + ['name' => 'urging', 'type' => 'int', 'length' => null], + ['name' => 'created', 'type' => 'datetime2', 'length' => null], + ['name' => 'updateTime', 'type' => 'datetime2', 'length' => null], + ]; + $selected = $datasets['jiansun.dbo.jst_workOrderInfo']['columns']; + foreach ($selected as $position => $name) { + $definition = collect($columns)->firstWhere('name', $name) ?? [ + 'name' => $name, + 'type' => 'nvarchar', + 'length' => 120, + ]; + DB::table('upstream_schema_columns')->insert(array_replace($this->column( + $definition['name'], + $position + 1, + $definition['type'], + $definition['length'], + ), [ + 'source_key' => 'jiansun', + 'schema_name' => 'dbo', + 'object_name' => 'jst_workOrderInfo', + ])); + } + + $dataset = (new BusinessMirrorDatasetRegistry())->get('jiansun.dbo.jst_workOrderInfo'); + $this->assertSame([ + ['woTypeId', 'woNo', 'Id'], + ['created', 'Id'], + ], $dataset->indexDefinitions()); + + (new BusinessMirrorSchemaManager())->ensure($dataset); + + $indexes = collect(Schema::connection('business_mirror')->getIndexes($dataset->targetTable())) + ->map(static fn (array $index): array => array_map('strtolower', (array) ($index['columns'] ?? []))) + ->all(); + $types = collect(DB::connection('business_mirror')->select('PRAGMA table_info(test_small_payment_work_orders)')) + ->pluck('type', 'name') + ->map(static fn (string $type): string => strtolower($type)); + $this->assertSame('varchar', $types['woNo']); + $this->assertSame('varchar', $types['woTypeId']); + $this->assertSame('text', $types['businessFieldList']); + $this->assertContains(['wotypeid', 'wono', 'id'], $indexes); + $this->assertContains(['created', 'id'], $indexes); + } + public function test_empty_full_read_does_not_delete_previous_mirror_rows(): void { $this->query->batches = [[ diff --git a/tests/Unit/EcommerceFinance/DingPayableWorkflowMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/DingPayableWorkflowMirrorSynchronizerTest.php index 0018d5d6..1badf847 100644 --- a/tests/Unit/EcommerceFinance/DingPayableWorkflowMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/DingPayableWorkflowMirrorSynchronizerTest.php @@ -159,6 +159,54 @@ class DingPayableWorkflowMirrorSynchronizerTest extends TestCase $this->assertSame(2, DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->count()); } + public function test_extended_workflow_update_removes_rows_that_no_longer_match(): void + { + config(['upstream_mirrors.ding_online_daifa_price' => [ + 'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price', + 'target_table' => 'ding_online_daifa_price_mirror', + 'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST', + 'extend_columns' => true, 'only_status' => 'COMPLETED', + 'column_map' => ['product_id' => '商品ID'], + ]]); + $sync = $this->synchronizer->forConfigKey('ding_online_daifa_price'); + $this->query->workflowBatches = [[$this->workflow('changed', now()->subHour()->format('Y-m-d H:i:s.u'))]]; + $sync->sync(true, 10, 0); + $table = DB::connection('business_mirror')->table('ding_online_daifa_price_mirror'); + $this->assertSame(1, $table->count()); + $this->query->workflowBatches = [[$this->workflow('changed', now()->format('Y-m-d H:i:s.u'), 'RUNNING')]]; + $sync->sync(false, 10, 0); + $this->assertSame(0, $table->count()); + } + + public function test_daifa_repository_includes_end_date_and_counts_only_resolved_products(): void + { + config(['upstream_mirrors.ding_online_daifa_price' => [ + 'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price', + 'target_table' => 'ding_online_daifa_price_mirror', + 'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST', + 'extend_columns' => true, 'column_map' => ['product_id' => '商品ID'], + ]]); + $this->query->workflowBatches = [[ + $this->workflow('A', '2026-08-01 00:00:00'), + $this->workflow('B', '2026-08-01 23:59:59'), + $this->workflow('C', '2026-08-02 00:00:00'), + ]]; + $this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0); + $db = DB::connection('business_mirror'); + $db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'A') + ->update(['product_id' => 'fixture-a', 'product_name' => '已匹配示例']); + $db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B') + ->update(['product_id' => 'fixture-b', 'product_name' => null]); + $db->enableQueryLog(); + $result = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class) + ->paginate(['created_from' => '2026-08-01', 'created_to' => '2026-08-01'], 1, 25); + $this->assertSame(2, $result['total']); + $this->assertSame(1, $result['summary']['matched_count']); + $this->assertSame(['B', 'A'], array_column($result['data'], 'process_instance_id')); + $queries = array_filter($db->getQueryLog(), fn ($q) => str_contains($q['query'], 'workflow_created_at')); + $this->assertContains('2026-08-02 00:00:00', array_values($queries)[0]['bindings']); + } + /** @return array */ private function workflow(string $id, string $updateAt, string $status = 'COMPLETED'): array {