发布小额打款索引修复与线上代发价格 #134

Merged
WuYongJie merged 11 commits from feature/WuYongJie-release-small-payment-daifa into main 2026-09-09 10:46:18 +08:00
16 changed files with 1327 additions and 24 deletions

View File

@ -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;

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
use Throwable;
class SyncDingOnlineDaifaPrice extends Command
{
protected $signature = 'business-mirror:sync-ding-online-daifa-price
{--full : 执行或继续首次全量同步}
{--batch= : 每批流程数量}
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
protected $description = '同步钉钉线上代发业务报销流程到共享业务镜像库';
public function handle(DingPayableWorkflowMirrorSynchronizer $synchronizer): int
{
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->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;
}
}

View File

@ -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 * * * *')

View File

@ -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 [

View File

@ -295,6 +295,7 @@ return [
'icon' => 'inventory_2',
'pages' => [
['slug' => 'normal-products', 'title' => '普通商品资料'],
['slug' => 'online-daifa-price', 'title' => '线上代发价格'],
],
],
[

View File

@ -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',

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
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->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');
}
};

View File

@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function mirrorConnection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->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');
});
}
}
};

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
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->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');
});
}
}
};

View File

@ -0,0 +1,176 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\DingPayableWorkflowStatus;
use RuntimeException;
/**
* 线上代发业务报销流程的共享镜像读取与按商品ID关联。
*
* 数据由 DingPayableWorkflowMirrorSynchronizer(ding_online_daifa_price 配置)
* 从钉钉 OA 审批同步到 ding_online_daifa_price_mirror 宽表。本仓库只在镜像库
* 读取,并依据 商品ID(product_id)关联共享业务镜像的商品目录,页面不直连上游。
*/
class DingOnlineDaifaPriceMirrorRepository
{
public const DATASET_KEY = 'operations_replica.rpt.ding_online_daifa_price';
public const TARGET_TABLE = 'ding_online_daifa_price_mirror';
private readonly ConnectionInterface $connection;
public function __construct(GenericBusinessMirrorRepository $mirror)
{
$this->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<array<string,mixed>>,total:int,status:string,last_synced_at:?string,summary:array<string,mixed>} */
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<object> $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;
}
}

View File

@ -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;

View File

@ -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<string,mixed>|null */
private function state(string $dataset): ?array
{
@ -438,4 +540,222 @@ class DingPayableWorkflowMirrorSynchronizer
}
return $grouped;
}
/**
* 从表单 scheme 解析每一行的“字段名 -> 值”映射。
*
* 若配置了明细表格字段名,则按该表格的每一行展开(并合并顶层字段,
* 如店铺名称),否则整张表单视为单行。返回数组长度即镜像明细行数。
*
* @param array<int,array<string,mixed>> $schemeRows
* @return list<array<string,mixed>>
*/
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<int,array<string,mixed>> $schemeRows @return array<string,mixed> */
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<string,mixed> $payload
* @param array<string,mixed> $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<array<string,mixed>> $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) {
// 忽略重置失败。
}
}
}
}

View File

@ -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
<div class="online-daifa-page">
<style>
.online-daifa-page { color: var(--vip-content-ink, #171717); font-size: .8125rem; }
.online-daifa-toolbar { display:flex; flex-wrap:wrap; align-items:flex-end; gap:.75rem; padding:1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-surface,#fff); }
.online-daifa-field { display:flex; flex-direction:column; gap:.35rem; min-width:14rem; }
.online-daifa-field label { color:var(--vip-content-muted,#595959); font-size:.75rem; font-weight:600; }
.online-daifa-field input,.online-daifa-field select { height:2.25rem; padding:0 .65rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); }
.online-daifa-field input:focus,.online-daifa-field select:focus { border-color:var(--vip-content-accent-dark,#856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.online-daifa-date-range { display:flex; align-items:center; gap:.5rem; }
.online-daifa-date-range input { min-width:9rem; }
.online-daifa-date-range span { color:var(--vip-content-muted,#595959); }
.online-daifa-actions { display:flex; gap:.5rem; }
.online-daifa-button { display:inline-flex; align-items:center; gap:.35rem; height:2.25rem; padding:0 .85rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); text-decoration:none; font-weight:600; }
.online-daifa-button.primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; }
.online-daifa-alert { padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-danger,#B80000); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); }
.online-daifa-meta { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; margin-bottom:.75rem; color:var(--vip-content-muted,#595959); font-size:.75rem; }
.online-daifa-meta b { color:var(--vip-content-ink,#171717); }
.online-daifa-summary { display:flex; flex-wrap:wrap; gap:1rem; padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-content-accent-soft,#F7F0DE); }
.online-daifa-summary .item { display:flex; flex-direction:column; gap:.15rem; }
.online-daifa-summary .item span { color:var(--vip-content-muted,#595959); font-size:.7rem; }
.online-daifa-summary .item b { font-size:.95rem; font-variant-numeric:tabular-nums; }
.online-daifa-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; }
.online-daifa-table { width:100%; min-width:120rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.online-daifa-table th,.online-daifa-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; }
.online-daifa-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; }
.online-daifa-table tr:hover td { background:#fcfaf4; }
.online-daifa-table .num { text-align:right; font-variant-numeric:tabular-nums; }
.online-daifa-table .unmatched { color:var(--vip-danger,#B80000); }
.online-daifa-detail { max-width:26rem; white-space:normal; }
.online-daifa-detail summary { cursor:pointer; color:var(--vip-content-accent-dark,#856632); }
.online-daifa-detail pre { max-width:36rem; max-height:18rem; overflow:auto; margin:.5rem 0 0; padding:.65rem; border:1px solid var(--vip-border,#D8D2C7); background:#faf9f6; white-space:pre-wrap; word-break:break-word; font:inherit; }
.online-daifa-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); }
.online-daifa-pagination { display:flex; justify-content:space-between; padding:.75rem 1rem; color:var(--vip-content-muted,#595959); }
.online-daifa-pagination a { color:var(--vip-content-accent-dark,#856632); margin-left:.75rem; }
</style>
<form method="GET" class="online-daifa-toolbar" aria-label="线上代发价格筛选">
<div class="online-daifa-field">
<label for="daifa-keyword">代发/店铺交易单号或审批编号</label>
<input id="daifa-keyword" name="keyword" value="{{ $filters['keyword'] ?? '' }}" placeholder="输入单据号或审批编号">
</div>
<div class="online-daifa-field">
<label for="daifa-product-id">商品ID</label>
<input id="daifa-product-id" name="product_id" value="{{ $filters['product_id'] ?? '' }}" placeholder="钉钉表单中的商品ID">
</div>
<div class="online-daifa-field">
<label for="daifa-shop-name">店铺名称</label>
<input id="daifa-shop-name" name="shop_name" value="{{ $filters['shop_name'] ?? '' }}" placeholder="如 抖音-HX旗舰店101">
</div>
<div class="online-daifa-field">
<label for="daifa-status">状态</label>
<select id="daifa-status" name="status">
@foreach ($statusOptions as $optionValue => $optionLabel)
<option value="{{ $optionValue }}" @selected(($filters['status'] ?? '') === $optionValue)>{{ $optionLabel }}</option>
@endforeach
</select>
</div>
<div class="online-daifa-field">
<label for="daifa-created-from">创建时间</label>
<div class="online-daifa-date-range">
<input id="daifa-created-from" type="date" name="created_from" value="{{ $filters['created_from'] ?? '' }}" aria-label="创建时间起">
<span aria-hidden="true">至</span>
<input id="daifa-created-to" type="date" name="created_to" value="{{ $filters['created_to'] ?? '' }}" aria-label="创建时间止">
</div>
</div>
<div class="online-daifa-actions">
<button class="online-daifa-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="online-daifa-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a>
</div>
</form>
@if ($error)
<div class="online-daifa-alert" role="alert">{{ $error }}</div>
@endif
<div class="online-daifa-meta">
<span>固定流程:线上代发业务报销流程(钉钉 OA 审批)</span>
@if ($payeeScope !== '') <span>固定收款人:<b>{{ $payeeScope }}</b></span> @endif
<span>镜像状态:{{ $status }}</span>
@if ($syncedAt) <span>最近同步:{{ $syncedAt }}</span> @endif
<span>共 <b>{{ $rows?->total() ?? 0 }}</b> 条</span>
</div>
<div class="online-daifa-summary">
<div class="item"><span>代发订单金额合计</span><b>{{ $money($summary['daifa_amount'] ?? null) }}</b></div>
<div class="item"><span>订单实付金额合计</span><b>{{ $money($summary['paid_amount'] ?? null) }}</b></div>
<div class="item"><span>差价合计</span><b>{{ $money($summary['price_diff'] ?? null) }}</b></div>
<div class="item"><span>已匹配商品目录</span><b>{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['count'] ?? 0) }}</b></div>
</div>
<div class="online-daifa-table-wrap">
<table class="online-daifa-table">
<thead><tr>
<th>审批编号</th><th>标题</th><th>状态</th><th>申请人</th><th>收款人全称</th>
<th>商品ID</th><th>商品名称(匹配)</th><th>店铺名称</th>
<th>代发交易单号</th><th>店铺交易单号</th>
<th class="num">发货数量</th><th class="num">订单实付金额</th><th class="num">代发订单金额</th><th class="num">差价</th>
<th>供应商名称</th><th>料号</th><th>创建时间</th><th>原始表单</th>
</tr></thead>
<tbody>
@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'] ?? '-')))
<tr>
<td>{{ $approvalNumber }}</td>
<td>{{ $workflow['title'] ?? '-' }}</td>
<td>{{ $row['status_label'] ?? '-' }}</td>
<td>{{ $originator }}</td>
<td>{{ $num($row['payee_name']) }}</td>
<td>{{ $num($row['product_id']) }}</td>
<td class="{{ ($row['matched_product']['matched'] ?? false) ? '' : 'unmatched' }}">{{ $productName($row) }}</td>
<td>{{ $num($row['shop_name']) }}</td>
<td>{{ $num($row['daifa_trade_no']) }}</td>
<td>{{ $num($row['shop_trade_no']) }}</td>
<td class="num">{{ $num($row['ship_qty']) }}</td>
<td class="num">{{ $money($row['paid_amount']) }}</td>
<td class="num">{{ $money($row['daifa_amount']) }}</td>
<td class="num">{{ $money($row['price_diff']) }}</td>
<td>{{ $num($row['supplier_name']) }}</td>
<td>{{ $num($row['material_no']) }}</td>
<td>{{ $row['workflow_created_at'] ?? '-' }}</td>
<td class="online-daifa-detail"><details><summary>展开表单({{ count($row['scheme'] ?? []) }} 项)</summary><pre>{{ $json($row['scheme'] ?? []) }}</pre></details></td>
</tr>
@empty
<tr><td colspan="18"><div class="online-daifa-empty">{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的线上代发价格数据;同步完成后会自动显示。' }}</div></td></tr>
@endforelse
</tbody>
</table>
<div class="online-daifa-pagination">
<span>当前页 {{ $rows?->count() ?? 0 }} 条 · 第 {{ $rows?->currentPage() ?? 1 }} 页</span>
<span>
@if ($rows && !$rows->onFirstPage())<a href="{{ $rows->previousPageUrl() }}">上一页</a>@endif
@if ($rows && $rows->hasMorePages())<a href="{{ $rows->nextPageUrl() }}">下一页</a>@endif
</span>
</div>
</div>
</div>

View File

@ -0,0 +1,131 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository;
use Tests\Feature\FeatureTestCase;
class OnlineDaifaPricePageTest extends FeatureTestCase
{
public function test_online_daifa_price_page_renders_matched_product_and_amounts(): void
{
$this->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');
}
}

View File

@ -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 = [[

View File

@ -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<string,string> */
private function workflow(string $id, string $updateAt, string $status = 'COMPLETED'): array
{