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/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index d515f525..962ab187 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -22,11 +22,9 @@ use Illuminate\Routing\Controller; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; -use Illuminate\Support\Carbon; use Illuminate\Database\QueryException; use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; -use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; @@ -39,11 +37,13 @@ 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; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData; +use Illuminate\Support\Carbon; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Throwable; @@ -79,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, @@ -248,73 +249,6 @@ class Pages extends Controller ]); } - public function exportBillOffset(Request $request): StreamedResponse - { - $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'order-data'); - abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'bill-offset'), 403, '当前账号没有该页面权限。'); - - $normalizeDate = static function (mixed $value): string { - $date = trim((string) $value); - if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) !== 1 - || ! checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1])) { - return ''; - } - return $date; - }; - $filters = [ - 'from' => $normalizeDate($request->query('from')) ?: now()->subDays(6)->toDateString(), - 'to' => $normalizeDate($request->query('to')) ?: now()->toDateString(), - 'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100), - 'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 100), - 'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100), - ]; - $repository = app(BillOffsetMirrorRepository::class); - $page = 1; - try { - $result = $repository->paginate($filters, $page, 100); - } catch (\RuntimeException $exception) { - if ($exception->getMessage() === 'bill_offset_mirror_not_ready') { - abort(503, '账单冲减镜像尚未完成首次同步'); - } - - throw $exception; - } - abort_if(($result['sync']['status'] ?? 'unknown') === 'unknown', 503, '账单冲减镜像尚未完成首次同步'); - - return response()->streamDownload(function () use ($repository, $filters, $result): void { - $csv = fopen('php://output', 'w'); - if ($csv === false) { - throw new \RuntimeException('无法创建账单冲减导出流。'); - } - fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); - $labels = [ - 'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号', - 'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码', - 'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码', - 'shop_style_code' => '店铺款式编码', 'amount' => '500112冲减收入', - ]; - fputcsv($csv, array_values($labels)); - $page = 1; - $current = $result; - do { - foreach ($current['data'] as $row) { - fputcsv($csv, array_map( - fn (string $field): string => $this->csvText($row[$field] ?? ''), - array_keys($labels), - )); - } - $page++; - if (($page - 1) * 100 < ($current['total'] ?? 0)) { - $current = $repository->paginate($filters, $page, 100); - } - } while (($page - 1) * 100 < ($current['total'] ?? 0)); - fclose($csv); - }, '账单冲减_' . now()->format('Ymd_His') . '.csv', [ - 'Content-Type' => 'text/csv; charset=UTF-8', - 'Cache-Control' => 'private, no-store', - ]); - } - protected function viewData( array $section, ?array $page = null, @@ -580,8 +514,8 @@ 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, }; } @@ -738,6 +672,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 [ @@ -1778,6 +1767,11 @@ class Pages extends Controller 'initialized' => false, 'last_succeeded_at' => null, ], + 'productCatalogPriceChangeSyncStatus' => $result['price_change_sync_status'] ?? [ + 'code' => 'unavailable', + 'initialized' => false, + 'last_succeeded_at' => null, + ], 'productCatalogWorkPlatformPriceSyncStatus' => $result['work_platform_price_sync_status'] ?? [ 'code' => 'unavailable', 'initialized' => false, @@ -1815,6 +1809,7 @@ class Pages extends Controller 'ID', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色', '品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价', '工作平台价格', '工作平台价格生效日期', '1688代发单价', + '变价前价格', '变价后价格', '差额', '变价时间', '数据来源', '市场价', '商品状态', '库存同步', '业务更新时间', ]); @@ -1843,8 +1838,12 @@ class Pages extends Controller $row->cost_price, $row->purchase_price, $row->work_platform_price ?? null, - $this->workPlatformPriceDate($row->work_platform_price_effect_time ?? null), + $this->priceChangeDate($row->work_platform_price_effect_time ?? null), $row->purchase_order_unit_price, + $row->price_change_before_price ?? null, + $row->price_change_after_price ?? null, + $row->price_change_diff_amount ?? null, + $this->priceChangeDate($row->price_change_effect_time ?? null), implode(';', $row->data_sources), $row->market_price, $enabledLabel, @@ -1860,7 +1859,11 @@ class Pages extends Controller ]); } - private function workPlatformPriceDate(mixed $value): string + /** + * 变价时间在上游是生效开始时间,页面与导出只展示到日期,避免把 + * 镜像中的零碎时间戳当成变价发生时刻。 + */ + private function priceChangeDate(mixed $value): string { if (! is_string($value) || trim($value) === '') { return ''; @@ -2001,6 +2004,11 @@ class Pages extends Controller 'initialized' => false, 'last_succeeded_at' => null, ], + 'productCatalogPriceChangeSyncStatus' => [ + 'code' => 'unavailable', + 'initialized' => false, + 'last_succeeded_at' => null, + ], 'productCatalogWorkPlatformPriceSyncStatus' => [ 'code' => 'unavailable', 'initialized' => false, diff --git a/config/business_navigation.php b/config/business_navigation.php index fea78ef6..ebd2cabb 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -192,7 +192,6 @@ return [ 'empty' => '店铺维度报表尚未完成共享数据导入。', 'fields' => [ ['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'], - ['name' => 'store_name', 'label' => '店铺名称'], ['name' => 'store_id', 'label' => '店铺ID(唯一值)'], ['name' => 'profit_center', 'label' => '店铺所属利润中心'], ['name' => 'store_manager', 'label' => '店铺负责人'], @@ -244,7 +243,7 @@ return [ 'directory' => [ 'icon' => 'key', 'status' => '共享镜像', - 'description' => '账号资料来自共享镜像。拥有本页面权限的用户可直接查看当前页密码,密码不会随表格导出。', + 'description' => '账号资料来自共享镜像。管理员可按需查看或复制密码,密码不会随表格导出。', 'empty' => '共享镜像中暂未读取到店铺账号数据,请检查账号数据同步状态。', 'fields' => [ ['name' => 'store_name', 'label' => '店铺名称'], @@ -295,6 +294,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 0d8d08d3..bb07e994 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -14,6 +14,58 @@ 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 DING_ONLINE_DAIFA_PRICE_PAYEE 设为该公司全称即可; + // 收款人全称是钉钉表单顶层字段,同步器会并入每条明细行按值精确匹配。 + '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', @@ -71,8 +123,12 @@ return [ 'reconcile_window_hours' => (int) env('PRODUCT_PURCHASE_PRICE_MIRROR_RECONCILE_WINDOW_HOURS', 6), ], - 'courier_information' => [ - 'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60), + // 普通商品资料的“变价前价格 / 变价后价格 / 差额 / 变价时间”来自 + // JianSun 旧业务库的商品价格记录表。页面按商品编码取最近一条有效记录, + // 不改动普通商品自身的采购价与成本价口径。 + 'product_price_changes' => [ + 'dataset_key' => 'jiansun.dbo.op_priceRecord', + 'target_table' => 'js_op_price_record', ], // 工作平台采购价格按 SKU 补充到普通商品资料;页面只读取共享镜像。 @@ -81,6 +137,10 @@ return [ 'target_table' => 'js_pm_price_adjustment', ], + 'courier_information' => [ + 'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60), + ], + /* |-------------------------------------------------------------------------- | 上游原表镜像目录 @@ -99,8 +159,10 @@ return [ 'max_datasets_per_run' => (int) env('BUSINESS_MIRROR_MAX_DATASETS', 1), 'overlap_hours' => (int) env('BUSINESS_MIRROR_OVERLAP_HOURS', 48), 'full_reconcile_hours' => (int) env('BUSINESS_MIRROR_FULL_RECONCILE_HOURS', 168), + // 这两个数据集有独立的定时入口,通用巡检不再重复同步。 'dedicated_datasets' => [ 'operations_replica.rpt.jst_sku', + 'jiansun.dbo.op_priceRecord', ], ], @@ -119,8 +181,6 @@ return [ 'indexes' => [ ['o_id', 'Id'], ['order_date', 'o_id', 'Id'], - // 账单明细按线上订单号 so_id 关联订单表;缺这个索引会让关联退化成百万行全表扫描。 - ['so_id', 'Id'], ], ], 'operations_replica.rpt.jst_OrderItem' => [ @@ -371,8 +431,35 @@ return [ 'jiansun.dbo.fys_supplierGoods_sttlement_purchase' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierGoods_sttlement_purchase', 'target' => 'js_supplier_goods_purchase', 'keys' => ['Id'], 'watermark' => 'createTime'], 'jiansun.dbo.fys_supplierGoods_sttlement_sale' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierGoods_sttlement_sale', 'target' => 'js_supplier_goods_sale', 'keys' => ['Id'], 'watermark' => 'createTime'], 'jiansun.dbo.pm_purchaseIn' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_purchaseIn', 'target' => 'js_pm_purchase_in', 'keys' => ['id'], 'watermark' => 'updateTime'], - // 工作平台采购价格:skuCode 对应 ops_jst_sku.sku_id。源表没有可靠更新时间, - // 以 Id 稳定追加,页面按 effectTime 取当前已生效的最新价格。 + // 普通商品资料的变价记录:barCode 对应 jst_sku.sku_id,页面按商品编码 + // 取最近一条未删除记录;差额由上游 diffPrice 直接提供,不在页面计算。 + 'jiansun.dbo.op_priceRecord' => [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'op_priceRecord', + 'target' => 'js_op_price_record', + 'keys' => ['Id'], + // updateTime 在上游对全部有效(delete=0)记录均为 NULL,不能用作 + // 增量水位线,否则增量同步永远捞不到任何有效行。createTime 始终有值, + // 与 Id 单调一致,作为增量水位线最稳妥。 + 'watermark' => 'createTime', + 'columns' => [ + 'Id', 'barCode', 'oldBarCode', 'originalPrice', 'afterPrice', + 'diffPrice', 'effectStartTime', 'effectEndTime', 'createTime', + 'updateTime', 'delete', + ], + // barCode 在源库是 varchar(100),镜像按 TEXT 保存,索引必须写前缀 + // 长度。商品编码远短于 64 字符,前缀索引即可命中页面查找。 + // createTime 索引支撑增量水位线区间扫描。 + 'indexes' => [ + ['barCode(64)', 'effectStartTime', 'Id'], + ['createTime', 'Id'], + ], + ], + + // 工作平台采购价格:skuCode 对应 ops_jst_sku.sku_id。源表只有 + // effectTime,没有可靠的修改时间,因此按 Id 追加;周期全量校准 + // 负责修正历史价格或删除记录,页面按 effectTime 取当前最新值。 'jiansun.dbo.pm_priceAdjustment' => [ 'source_key' => 'jiansun', 'schema' => 'dbo', 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..74268f26 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/DingOnlineDaifaPriceMirrorRepository.php @@ -0,0 +1,207 @@ +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(); + + // 计数/分页/合计一律走 SQL,只取当前页的行,避免把整表读入内存(大数据量会拖垮页面)。 + // 状态过滤:镜像在同步阶段按 only_status 只写入指定状态(当前=已完成);状态派生自 + // 表单 JSON、非独立列,无法用 SQL 过滤,因此请求的状态若与同步保留状态不同则结果为空, + // 否则全部行都符合、无需再过滤。 + $onlyStatus = strtoupper(trim((string) config('upstream_mirrors.ding_online_daifa_price.only_status', ''))); + $statusFilter = strtoupper(trim((string) ($filters['status'] ?? ''))); + $mismatchStatus = $statusFilter !== '' && $onlyStatus !== '' && $statusFilter !== $onlyStatus; + + $applyFilters = function ($query) use ($filters): void { + 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', '<', (string) $filters['created_to'] . ' 99:99:99'); + } + }; + + $countQuery = $this->connection->table(self::TARGET_TABLE); + $applyFilters($countQuery); + $total = $mismatchStatus ? 0 : (int) $countQuery->count(); + + $summary = ['count' => 0, 'matched_count' => 0, 'daifa_amount' => 0.0, 'paid_amount' => 0.0, 'price_diff' => 0.0]; + if (! $mismatchStatus) { + $summaryQuery = $this->connection->table(self::TARGET_TABLE); + $applyFilters($summaryQuery); + $agg = $summaryQuery->selectRaw( + 'COUNT(*) AS c, ' + . "SUM(CASE WHEN product_id IS NOT NULL AND product_id <> '' THEN 1 ELSE 0 END) AS mc, " + . 'COALESCE(SUM(daifa_amount), 0) AS da, ' + . 'COALESCE(SUM(paid_amount), 0) AS pa, ' + . 'COALESCE(SUM(price_diff), 0) AS pd' + )->first(); + $summary = [ + 'count' => (int) ($agg->c ?? 0), + 'matched_count' => (int) ($agg->mc ?? 0), + 'daifa_amount' => round((float) ($agg->da ?? 0), 2), + 'paid_amount' => round((float) ($agg->pa ?? 0), 2), + 'price_diff' => round((float) ($agg->pd ?? 0), 2), + ]; + } + + $pageRows = []; + if (! $mismatchStatus) { + $pageQuery = $this->connection->table(self::TARGET_TABLE); + $applyFilters($pageQuery); + $pageRows = $pageQuery + ->orderByDesc('workflow_created_at') + ->orderByDesc('process_instance_id') + ->orderByDesc('detail_index') + ->forPage($page, $perPage) + ->get(); + } + + $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 ?? ''), + // 标签:收款人为宁波顾家望业家居有限公司 => 刘萧代发;其余 => 1688代发。 + 'tag' => ((string) ($row->payee_name ?? '')) === '宁波顾家望业家居有限公司' ? '刘萧代发' : '1688代发', + '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' => $summary, + ]; + } + + /** @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)) { + $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/DingPayableWorkflowMirrorSynchronizer.php b/modules/EcommerceFinance/Services/DingPayableWorkflowMirrorSynchronizer.php index 167b2894..2f740388 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,37 @@ 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) { + $this->resolveProductNames($payloads); + } } $nextCursor = $this->cursorFromWorkflow($workflows[array_key_last($workflows)]); @@ -123,12 +174,20 @@ 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): void { 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 +225,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 +242,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 +534,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..ea7994e0 --- /dev/null +++ b/resources/views/business/pages/product-inventory/online-daifa-price.blade.php @@ -0,0 +1,158 @@ +@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']) }}{{ $row['tag'] ?? '' }}{{ $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'); + } +}