发布小额打款索引修复与线上代发价格 #134
@ -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;
|
||||
|
||||
49
app/Console/Commands/SyncDingOnlineDaifaPrice.php
Normal file
49
app/Console/Commands/SyncDingOnlineDaifaPrice.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 * * * *')
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -295,6 +295,7 @@ return [
|
||||
'icon' => 'inventory_2',
|
||||
'pages' => [
|
||||
['slug' => 'normal-products', 'title' => '普通商品资料'],
|
||||
['slug' => 'online-daifa-price', 'title' => '线上代发价格'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
// 忽略重置失败。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
131
tests/Feature/Business/OnlineDaifaPricePageTest.php
Normal file
131
tests/Feature/Business/OnlineDaifaPricePageTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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 = [[
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user