接入钉钉应付仓库车费流程 (#80)

This commit is contained in:
zhoushengchen 2026-08-29 09:34:07 +08:00
parent 8ea6cc5d78
commit 10ae05d2da
11 changed files with 811 additions and 0 deletions

View File

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

View 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;
}
}

View File

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

View File

@ -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' => '快递应付表'],
],
],

View File

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

View File

@ -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');
}
};

View File

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

View File

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

View File

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

View 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('镜像读取失败,请稍后重试。');
}
}

View 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();
}
}