接入钉钉应付仓库车费流程 (#80)
This commit is contained in:
parent
8ea6cc5d78
commit
10ae05d2da
@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
|
||||
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncBusinessMirrors extends Command
|
||||
@ -24,6 +25,7 @@ class SyncBusinessMirrors extends Command
|
||||
BusinessMirrorDatasetRegistry $datasets,
|
||||
BusinessMirrorSynchronizer $synchronizer,
|
||||
GenericBusinessMirrorRepository $mirror,
|
||||
?DingPayableWorkflowMirrorSynchronizer $dingPayableWorkflows = null,
|
||||
): int
|
||||
{
|
||||
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
|
||||
@ -76,6 +78,30 @@ class SyncBusinessMirrors extends Command
|
||||
));
|
||||
}
|
||||
|
||||
// 钉钉应付流程采用按流程聚合的专用镜像;接入同一入口后由现有
|
||||
// 生产 cron 自动断点续传,避免额外维护一套调度脚本。
|
||||
if ($requested === null && $dingPayableWorkflows !== null && ! app()->environment('testing')) {
|
||||
try {
|
||||
$result = $dingPayableWorkflows->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 ($result['status'] !== 'requires_full_sync') {
|
||||
$this->info(sprintf(
|
||||
'operations_replica.rpt.ding_payable_workflows:%s,本轮 %d 批,累计处理 %d 个流程。',
|
||||
$result['status'] === 'succeeded' ? '同步完成' : '同步进行中',
|
||||
$result['batches'],
|
||||
$result['rows'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$failed = true;
|
||||
Log::error('钉钉应付流程镜像同步失败。', ['exception' => $exception::class]);
|
||||
$this->error('operations_replica.rpt.ding_payable_workflows:同步失败,已保留上一版共享数据。');
|
||||
}
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
47
app/Console/Commands/SyncDingPayableWorkflowMirror.php
Normal file
47
app/Console/Commands/SyncDingPayableWorkflowMirror.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncDingPayableWorkflowMirror extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-ding-payable-workflows
|
||||
{--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->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;
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
|
||||
use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
|
||||
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
|
||||
@ -45,6 +46,7 @@ class Pages extends Controller
|
||||
private readonly ProductCatalogMirrorRepository $productCatalog,
|
||||
private readonly ProductPriceMirrorRepository $productPrices,
|
||||
private readonly RawBoardPayablesRepository $rawBoardPayables,
|
||||
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
|
||||
private readonly SupplierPayableReportRepository $supplierPayableReports,
|
||||
private readonly UpstreamQueryCacheFactory $upstreamCaches,
|
||||
) {
|
||||
@ -245,10 +247,53 @@ class Pages extends Controller
|
||||
'product-prices' => $this->contentForProductPrices(),
|
||||
'board-deductions' => $this->contentForBoardDeductions(),
|
||||
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
|
||||
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
protected function contentForDingPayableWorkflows(): array
|
||||
{
|
||||
$filters = [
|
||||
'process_instance_id' => mb_substr(trim((string) request('process_instance_id', '')), 0, 100),
|
||||
];
|
||||
$page = max((int) request('page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
|
||||
try {
|
||||
$result = $this->dingPayableWorkflows->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.courier-payables.payable-cloud-warehouse-vehicle-fee',
|
||||
'dingPayableWorkflowRows' => $rows,
|
||||
'dingPayableWorkflowFilters' => $filters,
|
||||
'dingPayableWorkflowStatus' => $result['status'] ?? 'unknown',
|
||||
'dingPayableWorkflowSyncedAt' => $result['last_synced_at'] ?? null,
|
||||
'dingPayableWorkflowError' => null,
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee',
|
||||
'dingPayableWorkflowRows' => new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
'path' => request()->url(), 'query' => request()->except('page'),
|
||||
]),
|
||||
'dingPayableWorkflowFilters' => $filters,
|
||||
'dingPayableWorkflowStatus' => 'unavailable',
|
||||
'dingPayableWorkflowSyncedAt' => null,
|
||||
'dingPayableWorkflowError' => '钉钉应付流程镜像尚未完成同步,请稍后重试。',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function contentForNonJingxiRefundData(): array
|
||||
{
|
||||
$request = request();
|
||||
|
||||
@ -424,6 +424,7 @@ return [
|
||||
['slug' => 'compensation-details', 'title' => '赔偿明细'],
|
||||
['slug' => 'advance-details', 'title' => '借支明细'],
|
||||
['slug' => 'other-adjustments', 'title' => '其他扣款补款'],
|
||||
['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'],
|
||||
['slug' => 'courier-payables-report', 'title' => '快递应付表'],
|
||||
],
|
||||
],
|
||||
|
||||
@ -3,6 +3,17 @@
|
||||
return [
|
||||
'connection' => env('BUSINESS_MIRROR_DB_CONNECTION', 'business_mirror'),
|
||||
|
||||
// 钉钉应付申请流程只同步“应付申请流程”这一固定流程编码,
|
||||
// 页面从共享业务镜像读取,不在 HTTP 请求中直连上游。
|
||||
'ding_payable_workflows' => [
|
||||
'dataset_key' => 'operations_replica.rpt.ding_payable_workflows',
|
||||
'source_key' => 'operations_replica',
|
||||
'process_code' => 'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88',
|
||||
'target_table' => 'ding_payable_workflow_mirror',
|
||||
'batch_size' => (int) env('DING_PAYABLE_WORKFLOW_MIRROR_BATCH_SIZE', 100),
|
||||
'max_batches_per_run' => (int) env('DING_PAYABLE_WORKFLOW_MIRROR_MAX_BATCHES', 2),
|
||||
],
|
||||
|
||||
// 只有生产刷新节点写镜像;开发环境和网页请求只读共享结果。
|
||||
'refresh_enabled' => env(
|
||||
'BUSINESS_MIRROR_REFRESH_ENABLED',
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
<?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_payable_workflow_mirror')) {
|
||||
$schema->create('ding_payable_workflow_mirror', function (Blueprint $table): void {
|
||||
$table->string('process_instance_id', 100)->primary();
|
||||
$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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::connection($this->connection())->dropIfExists('ding_payable_workflow_mirror');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class DingPayableWorkflowMirrorRepository
|
||||
{
|
||||
public const DATASET_KEY = 'operations_replica.rpt.ding_payable_workflows';
|
||||
|
||||
public const PROCESS_CODE = 'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88';
|
||||
|
||||
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('ding_payable_workflow_mirror') || ! $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} */
|
||||
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();
|
||||
// 先在镜像库按固定业务关键词缩小候选流程,再解析 JSON;避免为每次页面请求
|
||||
// 解码全部历史表单。关键词来自本任务的固定业务筛选,不是用户拼接 SQL。
|
||||
// “仓”覆盖江西仓、重庆仓等实际用途写法;后续仍在解析明细时
|
||||
// 同时校验类型和车费,避免仅因其他字段出现“仓”而误收流程。
|
||||
$records = $this->connection->table('ding_payable_workflow_mirror')
|
||||
->where('scheme_payload', 'like', '%快递运费%')
|
||||
->where('scheme_payload', 'like', '%仓%')
|
||||
->where('scheme_payload', 'like', '%车费%')
|
||||
->orderByDesc('workflow_created_at')->orderByDesc('process_instance_id')->get();
|
||||
$filtered = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$workflow = $this->decode((string) $record->workflow_payload, []);
|
||||
if ((string) ($workflow['processCode'] ?? '') !== self::PROCESS_CODE) {
|
||||
continue;
|
||||
}
|
||||
if (($filters['process_instance_id'] ?? '') !== ''
|
||||
&& ! str_contains((string) ($workflow['processInstanceId'] ?? ''), (string) $filters['process_instance_id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scheme = $this->decode((string) $record->scheme_payload, []);
|
||||
$forms = $this->formFields($scheme);
|
||||
foreach ($this->detailRows($forms['应付明细'] ?? []) as $detail) {
|
||||
$type = trim((string) ($detail['类型'] ?? ''));
|
||||
$purpose = trim((string) ($detail['用途'] ?? ''));
|
||||
if ($type !== '快递运费' || ! str_contains($purpose, '仓') || ! str_contains($purpose, '车费')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = [
|
||||
'process_instance_id' => (string) ($workflow['processInstanceId'] ?? $record->process_instance_id),
|
||||
'workflow' => $workflow,
|
||||
'fields' => $forms,
|
||||
'detail' => $detail,
|
||||
'tasks' => $this->decode((string) ($record->task_payload ?? '[]'), []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$total = count($filtered);
|
||||
$data = array_slice($filtered, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'total' => $total,
|
||||
'status' => (string) ($state->status ?? 'unknown'),
|
||||
'last_synced_at' => $state->last_succeeded_at ? (string) $state->last_succeeded_at : null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function formFields(array $rows): array
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$value = $this->decode((string) ($row['value'] ?? ''), $row['value'] ?? null);
|
||||
$fields[$name] = $value;
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function detailRows(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
$details = [];
|
||||
foreach ($value as $row) {
|
||||
if (! is_array($row) || ! isset($row['rowValue']) || ! is_array($row['rowValue'])) {
|
||||
continue;
|
||||
}
|
||||
$detail = [];
|
||||
foreach ($row['rowValue'] as $field) {
|
||||
if (! is_array($field) || ! isset($field['label'])) {
|
||||
continue;
|
||||
}
|
||||
$detail[(string) $field['label']] = $this->scalarValue($field['value'] ?? null);
|
||||
}
|
||||
if ($detail !== []) {
|
||||
$details[] = $detail;
|
||||
}
|
||||
}
|
||||
return $details;
|
||||
}
|
||||
|
||||
private function scalarValue(mixed $value): mixed
|
||||
{
|
||||
if (is_scalar($value) || $value === null) {
|
||||
return $value;
|
||||
}
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
private function decode(string $json, mixed $fallback): mixed
|
||||
{
|
||||
if ($json === '') {
|
||||
return $fallback;
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : $fallback;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 将钉钉应付申请流程的固定流程编码同步为按流程聚合的共享镜像。
|
||||
*
|
||||
* 钉钉表单字段存放在 JSON 中,且历史版本字段不完全一致;镜像保留原始
|
||||
* 主表、表单和审批任务 payload,页面只在镜像库中解析和筛选。
|
||||
*/
|
||||
class DingPayableWorkflowMirrorSynchronizer
|
||||
{
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
|
||||
{
|
||||
$this->connection = app('db')->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror')
|
||||
);
|
||||
}
|
||||
|
||||
/** @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', []);
|
||||
$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');
|
||||
$processCode = (string) ($config['process_code'] ?? '');
|
||||
|
||||
if ($processCode === '') {
|
||||
throw new RuntimeException('ding_payable_workflow_mirror_not_initialized');
|
||||
}
|
||||
|
||||
$this->ensureMirrorTable($target);
|
||||
|
||||
UpstreamOdbcConnection::reset($source);
|
||||
$state = $this->state($dataset);
|
||||
|
||||
if (! $full && ($state['initial_completed_at'] ?? null) === null) {
|
||||
return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 0, 'rows' => 0, 'dataset' => $dataset];
|
||||
}
|
||||
|
||||
$mode = $full ? 'full' : 'incremental';
|
||||
$resume = is_array($state)
|
||||
&& ($state['run_mode'] ?? null) === $mode
|
||||
&& in_array($state['status'] ?? null, ['running', 'failed'], true)
|
||||
&& is_string($state['generation'] ?? null)
|
||||
&& is_array($state['cursor'] ?? null);
|
||||
$generation = $resume ? (string) $state['generation'] : (string) Str::uuid();
|
||||
$cursor = $resume
|
||||
? (string) ($state['cursor']['processInstanceId'] ?? '')
|
||||
: (string) ($this->decodeJson($state['high_watermark'] ?? null)['processInstanceId'] ?? '');
|
||||
$rows = $resume ? (int) ($state['last_upserted_rows'] ?? 0) : 0;
|
||||
$batchSize = min(max($batchSize ?? (int) ($config['batch_size'] ?? 100), 1), 500);
|
||||
$maxBatches = max($maxBatches ?? (int) ($config['max_batches_per_run'] ?? 2), 0);
|
||||
|
||||
$this->start($dataset, $target, $source, $mode, $generation, $cursor, $resume);
|
||||
$batches = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
$workflows = $this->readWorkflows($source, $processCode, $cursor, $batchSize);
|
||||
if ($workflows === []) {
|
||||
$this->complete($dataset, $target, $mode, $generation, $rows, $cursor);
|
||||
|
||||
return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset];
|
||||
}
|
||||
|
||||
$ids = array_values(array_filter(array_map(
|
||||
static fn (array $row): string => (string) ($row['processInstanceId'] ?? ''),
|
||||
$workflows,
|
||||
)));
|
||||
$schemes = $this->readRelated($source, 'ding_WorkflowScheme', ['id', 'processInstanceId', 'componentType', 'name', 'value', 'extValue'], $ids, 'id');
|
||||
$tasks = $this->readRelated($source, 'ding_WorkflowTask', ['processInstanceId', 'result', 'activityId', 'finishTime', 'createTime', 'userId', 'taskId', 'status', 'id'], $ids, 'id');
|
||||
|
||||
$schemeById = $this->groupBy($schemes, 'processInstanceId');
|
||||
$taskById = $this->groupBy($tasks, 'processInstanceId');
|
||||
$now = now();
|
||||
$payloads = [];
|
||||
|
||||
foreach ($workflows as $workflow) {
|
||||
$id = (string) ($workflow['processInstanceId'] ?? '');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
$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,
|
||||
];
|
||||
}
|
||||
|
||||
$cursor = (string) ($workflows[array_key_last($workflows)]['processInstanceId'] ?? $cursor);
|
||||
$rows += count($payloads);
|
||||
$batches++;
|
||||
$this->connection->transaction(function () use ($dataset, $target, $payloads, $cursor, $generation, $rows, $now): void {
|
||||
if ($payloads !== []) {
|
||||
$this->connection->table($target)->upsert(
|
||||
$payloads,
|
||||
['process_instance_id'],
|
||||
['workflow_payload', 'scheme_payload', 'task_payload', 'workflow_created_at', 'sync_generation', 'synced_at'],
|
||||
);
|
||||
}
|
||||
$this->connection->table('business_mirror_table_states')
|
||||
->where('dataset_key', $dataset)
|
||||
->update([
|
||||
'cursor' => json_encode(['processInstanceId' => $cursor], JSON_UNESCAPED_UNICODE),
|
||||
'last_upserted_rows' => $rows,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
|
||||
if (count($workflows) < $batchSize) {
|
||||
$this->complete($dataset, $target, $mode, $generation, $rows, $cursor);
|
||||
|
||||
return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset];
|
||||
}
|
||||
|
||||
if ($maxBatches > 0 && $batches >= $maxBatches) {
|
||||
return ['status' => 'running', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->connection->table('business_mirror_table_states')
|
||||
->where('dataset_key', $dataset)
|
||||
->update(['status' => 'failed', 'last_error_code' => mb_substr($exception::class, 0, 120), 'updated_at' => now()]);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureMirrorTable(string $target): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
if ($schema->hasTable($target)) {
|
||||
return;
|
||||
}
|
||||
$schema->create($target, function (Blueprint $table): void {
|
||||
$table->string('process_instance_id', 100)->primary();
|
||||
$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);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function state(string $dataset): ?array
|
||||
{
|
||||
$row = $this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset)->first();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
$state = (array) $row;
|
||||
$state['cursor'] = is_string($state['cursor'] ?? null) ? (json_decode($state['cursor'], true) ?: []) : ($state['cursor'] ?? []);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function start(string $dataset, string $target, string $source, string $mode, string $generation, string $cursor, bool $resume): void
|
||||
{
|
||||
$now = now();
|
||||
$values = [
|
||||
'source_key' => $source,
|
||||
'source_object' => 'rpt.ding_Workflow + rpt.ding_WorkflowScheme + rpt.ding_WorkflowTask',
|
||||
'target_table' => $target,
|
||||
'status' => 'running',
|
||||
'run_mode' => $mode,
|
||||
'generation' => $generation,
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (! $resume) {
|
||||
$values += ['cursor' => null, 'window_started_at' => null, 'window_ends_at' => null, 'last_upserted_rows' => 0];
|
||||
}
|
||||
$query = $this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset);
|
||||
$query->exists()
|
||||
? $query->update($values)
|
||||
: $this->connection->table('business_mirror_table_states')->insert(['dataset_key' => $dataset, 'created_at' => $now] + $values);
|
||||
}
|
||||
|
||||
private function complete(string $dataset, string $target, string $mode, string $generation, int $rows, string $cursor): void
|
||||
{
|
||||
$now = now();
|
||||
$this->connection->transaction(function () use ($dataset, $target, $mode, $generation, $rows, $cursor, $now): void {
|
||||
if ($mode === 'full') {
|
||||
$this->connection->table($target)->where(function ($query) use ($generation): void {
|
||||
$query->whereNull('sync_generation')->orWhere('sync_generation', '!=', $generation);
|
||||
})->delete();
|
||||
}
|
||||
$values = [
|
||||
'status' => 'succeeded',
|
||||
'cursor' => null,
|
||||
'high_watermark' => json_encode(['processInstanceId' => $cursor], JSON_UNESCAPED_UNICODE),
|
||||
'last_succeeded_at' => $now,
|
||||
'last_upserted_rows' => $rows,
|
||||
'mirror_row_count' => $this->connection->table($target)->count(),
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($mode === 'full') {
|
||||
$values['initial_completed_at'] = $now;
|
||||
$values['last_full_succeeded_at'] = $now;
|
||||
}
|
||||
$this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset)->update($values);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function decodeJson(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (! is_string($value) || $value === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function readWorkflows(string $source, string $processCode, string $cursor, int $limit): array
|
||||
{
|
||||
$where = '[processCode] = ?';
|
||||
$bindings = [$processCode];
|
||||
if ($cursor !== '') {
|
||||
$where .= ' AND [processInstanceId] > ?';
|
||||
$bindings[] = $cursor;
|
||||
}
|
||||
return $this->queries->forSource($source)->select(
|
||||
'SELECT TOP (' . $limit . ') [finishTime],[businessId],[title],[result],[originatorUserId],[originatorDeptId],[originatorDeptName],[status],[bizAction],[processInstanceId],[joinAt],[updateAt],[createAt],[processCode],[flowType] FROM [rpt].[ding_Workflow] WHERE ' . $where . ' ORDER BY [processInstanceId]',
|
||||
$bindings,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<string> $ids @param list<string> $columns @return list<array<string,mixed>> */
|
||||
private function readRelated(string $source, string $table, array $columns, array $ids, string $order): array
|
||||
{
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
return $this->queries->forSource($source)->select(
|
||||
'SELECT ' . implode(', ', array_map(static fn (string $column): string => '[' . $column . ']', $columns))
|
||||
. ' FROM [rpt].[' . $table . '] WHERE [processInstanceId] IN (' . $placeholders . ') ORDER BY [' . $order . ']',
|
||||
$ids,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows @return array<string,list<array<string,mixed>>> */
|
||||
private function groupBy(array $rows, string $column): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = (string) ($row[$column] ?? '');
|
||||
if ($key !== '') {
|
||||
$grouped[$key][] = $row;
|
||||
}
|
||||
}
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
@php
|
||||
$rows = $dingPayableWorkflowRows ?? null;
|
||||
$filters = $dingPayableWorkflowFilters ?? [];
|
||||
$status = (string) ($dingPayableWorkflowStatus ?? 'unknown');
|
||||
$syncedAt = $dingPayableWorkflowSyncedAt ?? null;
|
||||
$error = $dingPayableWorkflowError ?? null;
|
||||
$json = static fn (mixed $value): string => is_array($value)
|
||||
? (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '-')
|
||||
: (string) ($value ?? '-');
|
||||
$value = static function (array $detail, string $key): string {
|
||||
$v = $detail[$key] ?? null;
|
||||
return $v === null || $v === '' ? '-' : (string) $v;
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="ding-payable-page">
|
||||
<style>
|
||||
.ding-payable-page { color: var(--vip-content-ink, #171717); font-size: .8125rem; }
|
||||
.ding-payable-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); }
|
||||
.ding-payable-field { display:flex; flex-direction:column; gap:.35rem; min-width:16rem; }
|
||||
.ding-payable-field label { color:var(--vip-content-muted,#595959); font-size:.75rem; font-weight:600; }
|
||||
.ding-payable-field input { height:2.25rem; padding:0 .65rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); }
|
||||
.ding-payable-field input:focus { border-color:var(--vip-content-accent-dark,#856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.ding-payable-actions { display:flex; gap:.5rem; }
|
||||
.ding-payable-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; }
|
||||
.ding-payable-button.primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; }
|
||||
.ding-payable-alert { padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-danger,#B80000); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); }
|
||||
.ding-payable-meta { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; margin-bottom:.75rem; color:var(--vip-content-muted,#595959); font-size:.75rem; }
|
||||
.ding-payable-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; }
|
||||
.ding-payable-table { width:100%; min-width:125rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
|
||||
.ding-payable-table th,.ding-payable-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; }
|
||||
.ding-payable-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; }
|
||||
.ding-payable-table tr:hover td { background:#fcfaf4; }
|
||||
.ding-payable-table .num { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.ding-payable-detail { max-width:26rem; white-space:normal; }
|
||||
.ding-payable-detail summary { cursor:pointer; color:var(--vip-content-accent-dark,#856632); }
|
||||
.ding-payable-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; }
|
||||
.ding-payable-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); }
|
||||
.ding-payable-pagination { display:flex; justify-content:space-between; padding:.75rem 1rem; color:var(--vip-content-muted,#595959); }
|
||||
.ding-payable-pagination a { color:var(--vip-content-accent-dark,#856632); margin-left:.75rem; }
|
||||
</style>
|
||||
|
||||
<form method="GET" class="ding-payable-toolbar" aria-label="应付云仓车费筛选">
|
||||
<div class="ding-payable-field">
|
||||
<label for="ding-payable-process-id">流程实例 ID(可选)</label>
|
||||
<input id="ding-payable-process-id" name="process_instance_id" value="{{ $filters['process_instance_id'] ?? '' }}" placeholder="输入流程实例 ID">
|
||||
</div>
|
||||
<div class="ding-payable-actions">
|
||||
<button class="ding-payable-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
|
||||
<a class="ding-payable-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="ding-payable-alert" role="alert">{{ $error }}</div>
|
||||
@endif
|
||||
|
||||
<div class="ding-payable-meta">
|
||||
<span>固定流程:应付申请流程</span>
|
||||
<span>筛选:类型=快递运费,且用途同时包含“仓”“车费”</span>
|
||||
<span>镜像状态:{{ $status }}</span>
|
||||
@if ($syncedAt) <span>最近同步:{{ $syncedAt }}</span> @endif
|
||||
<span>共 {{ $rows?->total() ?? 0 }} 条明细</span>
|
||||
</div>
|
||||
|
||||
<div class="ding-payable-table-wrap">
|
||||
<table class="ding-payable-table">
|
||||
<thead><tr>
|
||||
<th>流程实例 ID</th><th>标题</th><th>状态</th><th>申请人</th><th>申请部门</th><th>创建时间</th><th>完成时间</th>
|
||||
<th>类型</th><th>用途</th><th class="num">付款金额</th><th>货款月份</th><th>快递</th><th>支付方式</th><th>收款人全称</th><th>开户银行</th><th>银行账号</th>
|
||||
<th>全部表单字段</th><th>付款明细</th><th>附件</th><th>审批任务</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@forelse ($rows ?? [] as $row)
|
||||
@php($workflow = $row['workflow'] ?? [])
|
||||
@php($detail = $row['detail'] ?? [])
|
||||
@php($fields = $row['fields'] ?? [])
|
||||
<tr>
|
||||
<td>{{ $row['process_instance_id'] ?? '-' }}</td>
|
||||
<td>{{ $workflow['title'] ?? '-' }}</td>
|
||||
<td>{{ $workflow['status'] ?? '-' }}</td>
|
||||
<td>{{ $workflow['originatorUserId'] ?? '-' }}</td>
|
||||
<td>{{ $workflow['originatorDeptName'] ?? '-' }}</td>
|
||||
<td>{{ $workflow['createAt'] ?? '-' }}</td>
|
||||
<td>{{ $workflow['finishTime'] ?? '-' }}</td>
|
||||
<td>{{ $value($detail, '类型') }}</td>
|
||||
<td class="ding-payable-detail">{{ $value($detail, '用途') }}</td>
|
||||
<td class="num">{{ $value($detail, '付款金额') }}</td>
|
||||
<td>{{ $value($detail, '货款月份') }}</td>
|
||||
<td>{{ $value($detail, '快递') }}</td>
|
||||
<td>{{ $value($detail, '支付方式') }}</td>
|
||||
<td>{{ $value($detail, '收款人全称') }}</td>
|
||||
<td>{{ $value($detail, '开户银行') }}</td>
|
||||
<td>{{ $value($detail, '银行账号') }}</td>
|
||||
<td class="ding-payable-detail"><details><summary>展开字段({{ count($fields) }} 项)</summary><pre>{{ $json($fields) }}</pre></details></td>
|
||||
<td class="ding-payable-detail"><details><summary>展开付款明细</summary><pre>{{ $json($fields['付款明细'] ?? []) }}</pre></details></td>
|
||||
<td class="ding-payable-detail"><details><summary>展开附件</summary><pre>{{ $json($fields['附件'] ?? ($fields['未签字版合同附件或其他明细附件'] ?? [])) }}</pre></details></td>
|
||||
<td class="ding-payable-detail"><details><summary>展开审批任务</summary><pre>{{ $json($row['tasks'] ?? []) }}</pre></details></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="20"><div class="ding-payable-empty">{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的应付云仓车费流程;同步完成后会自动显示。' }}</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="ding-payable-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>
|
||||
63
tests/Feature/Business/DingPayableWorkflowPageTest.php
Normal file
63
tests/Feature/Business/DingPayableWorkflowPageTest.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class DingPayableWorkflowPageTest extends FeatureTestCase
|
||||
{
|
||||
public function test_cloud_warehouse_vehicle_fee_page_renders_all_workflow_sections(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$repository = Mockery::mock(DingPayableWorkflowMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->andReturn([
|
||||
'data' => [[
|
||||
'process_instance_id' => 'PROC-TEST-001',
|
||||
'workflow' => [
|
||||
'title' => '应付申请流程',
|
||||
'status' => 'COMPLETED',
|
||||
'originatorUserId' => 'user-1',
|
||||
'originatorDeptName' => '财务部',
|
||||
'createAt' => '2026-08-28 10:00:00',
|
||||
'finishTime' => '2026-08-28 11:00:00',
|
||||
],
|
||||
'detail' => ['类型' => '快递运费', '用途' => '拉到江西仓的车费', '付款金额' => '1200'],
|
||||
'fields' => ['应付明细' => [], '付款明细' => [['付款金额' => '1200']], '附件' => []],
|
||||
'tasks' => [['status' => 'COMPLETED']],
|
||||
]],
|
||||
'total' => 1,
|
||||
'status' => 'succeeded',
|
||||
'last_synced_at' => '2026-08-28 11:05:00',
|
||||
]);
|
||||
$this->app->instance(DingPayableWorkflowMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'payable-cloud-warehouse-vehicle-fee',
|
||||
]))->assertOk()
|
||||
->assertSee('应付云仓车费')
|
||||
->assertSee('拉到江西仓的车费')
|
||||
->assertSee('用途同时包含“仓”“车费”')
|
||||
->assertSee('全部表单字段')
|
||||
->assertSee('审批任务')
|
||||
->assertSee('镜像状态:succeeded');
|
||||
}
|
||||
|
||||
public function test_cloud_warehouse_vehicle_fee_page_keeps_an_unavailable_mirror_readable(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$repository = Mockery::mock(DingPayableWorkflowMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->andThrow(new \RuntimeException('not ready'));
|
||||
$this->app->instance(DingPayableWorkflowMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'payable-cloud-warehouse-vehicle-fee',
|
||||
]))->assertOk()
|
||||
->assertSee('钉钉应付流程镜像尚未完成同步,请稍后重试。')
|
||||
->assertSee('镜像读取失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
28
tests/Feature/Console/DingPayableWorkflowMirrorSyncTest.php
Normal file
28
tests/Feature/Console/DingPayableWorkflowMirrorSyncTest.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DingPayableWorkflowMirrorSyncTest extends TestCase
|
||||
{
|
||||
public function test_explicit_full_sync_is_forwarded_to_the_workflow_synchronizer(): void
|
||||
{
|
||||
config(['upstream_mirrors.refresh_enabled' => false]);
|
||||
$synchronizer = Mockery::mock(DingPayableWorkflowMirrorSynchronizer::class);
|
||||
$synchronizer->shouldReceive('sync')->once()->with(true, 25, 1)->andReturn([
|
||||
'status' => 'succeeded', 'mode' => 'full', 'batches' => 1, 'rows' => 2,
|
||||
'dataset' => 'operations_replica.rpt.ding_payable_workflows',
|
||||
]);
|
||||
$this->app->instance(DingPayableWorkflowMirrorSynchronizer::class, $synchronizer);
|
||||
|
||||
$this->artisan('business-mirror:sync-ding-payable-workflows', [
|
||||
'--full' => true,
|
||||
'--batch' => 25,
|
||||
'--max-batches' => 1,
|
||||
])->expectsOutputToContain('同步完成')
|
||||
->assertSuccessful();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user