feat: 财务经营报表新增流程明细页面 #608

Merged
yueyang merged 2 commits from feature/yueyang-process-details into main 2026-10-26 16:31:04 +08:00
13 changed files with 1908 additions and 0 deletions

View File

@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorImporter;
use Throwable;
class ImportDingProcessDetails extends Command
{
protected $signature = 'ding:import-process-details
{file : 15 列流程明细 CSV 文件路径}
{--scope=personal_permission : 数据权限范围标识}
{--replace : 导入前清空同一范围的数据}';
protected $description = '导入钉钉流程明细到共享业务镜像';
public function handle(DingProcessDetailMirrorImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('file'),
(string) $this->option('scope'),
(bool) $this->option('replace'),
);
} catch (Throwable $exception) {
$this->error('导入失败:' . $exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'导入完成:%d 行,跳过占位 %d 行,跳过无效 %d 行,重复 %d 行,范围 %s。',
$result['rows'],
$result['placeholders'],
$result['invalid'],
$result['duplicates'],
$result['scope'],
));
return self::SUCCESS;
}
}

View File

@ -10,6 +10,29 @@ class BusinessNavigation
public static function all(): array
{
$navigation = config('business_navigation', []);
$pageExtensions = (array) config('business_navigation_page_extensions', []);
foreach ($navigation as &$section) {
$sectionSlug = (string) ($section['slug'] ?? '');
foreach ((array) ($pageExtensions[$sectionSlug] ?? []) as $extension) {
$extensionSlug = trim((string) ($extension['slug'] ?? ''));
if ($extensionSlug === '') {
continue;
}
$exists = collect($section['pages'] ?? [])->contains(
static fn (array $page): bool => (string) ($page['slug'] ?? '') === $extensionSlug
);
if (! $exists) {
$section['pages'][] = $extension;
}
}
}
unset($section);
$titles = (array) setting('navigation.titles', []);
$sectionTitles = (array) ($titles['sections'] ?? []);
$pageTitles = (array) ($titles['pages'] ?? []);

View File

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Module-owned page extensions
|--------------------------------------------------------------------------
|
| Keep additive page entries out of the shared navigation configuration.
| Entries are appended to an existing section in configuration order.
|
*/
'financial-operations' => [
[
'slug' => 'process-details',
'title' => '流程明细',
],
],
];

View File

@ -0,0 +1,68 @@
<?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_process_detail_mirror')) {
return;
}
$schema->create('ding_process_detail_mirror', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('business_id', 32);
$table->unsignedInteger('line_no');
$table->string('business_month', 6)->index();
$table->string('originator_name', 100)->nullable()->index();
$table->string('status', 20)->nullable()->index();
$table->string('document_type', 100)->nullable()->index();
$table->text('purpose')->nullable();
$table->string('account_code', 64)->nullable();
$table->string('account_name', 255)->nullable();
$table->decimal('amount', 18, 2)->nullable();
$table->string('bank_account_code', 255)->nullable();
$table->string('bank_account_name', 255)->nullable();
$table->decimal('bank_amount', 18, 2)->nullable();
$table->string('payment_method', 50)->nullable();
$table->string('paid_at', 50)->nullable();
$table->string('source_scope', 40)->default('personal_permission')->index();
$table->dateTime('synced_at', 6);
$table->timestamps();
$table->unique(
['business_id', 'line_no'],
'ding_process_detail_business_line_unique',
);
$table->index(
['business_month', 'document_type'],
'ding_process_detail_month_type_index',
);
});
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
Schema::connection($this->connection())->dropIfExists('ding_process_detail_mirror');
}
};

View File

@ -0,0 +1,139 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Repositories\DingProcessDetailMirrorRepository;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorImporter;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class ProcessDetails extends Controller
{
public function __construct(
private readonly DingProcessDetailMirrorRepository $repository,
) {}
public function show(Request $request)
{
$filters = $this->filters($request);
$page = max((int) $request->query('page', 1), 1);
$perPage = 50;
try {
$result = $this->repository->paginate($filters, $page, $perPage);
$options = $this->repository->filterOptions();
return view('business.pages.financial-operations.process-details', [
'section' => ['slug' => 'financial-operations', 'title' => '财务经营报表'],
'page' => ['slug' => 'process-details', 'title' => '流程明细'],
'rows' => new LengthAwarePaginator(
$result['data'],
$result['total'],
$perPage,
$page,
['path' => $request->url(), 'query' => $request->except('page')],
),
'filters' => $filters,
'summary' => $result['summary'],
'months' => $options['months'],
'documentTypes' => $options['document_types'],
'statuses' => $options['statuses'],
'mirrorStatus' => $result['status'],
'mirrorSyncedAt' => $result['last_synced_at'],
'scope' => $result['scope'],
'error' => null,
]);
} catch (Throwable $exception) {
report($exception);
return view('business.pages.financial-operations.process-details', [
'section' => ['slug' => 'financial-operations', 'title' => '财务经营报表'],
'page' => ['slug' => 'process-details', 'title' => '流程明细'],
'rows' => new LengthAwarePaginator([], 0, $perPage, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]),
'filters' => $filters,
'summary' => [
'rows' => 0,
'approvals' => 0,
'amount_total' => 0.0,
'bank_amount_total' => 0.0,
],
'months' => [],
'documentTypes' => [],
'statuses' => [],
'mirrorStatus' => 'unavailable',
'mirrorSyncedAt' => null,
'scope' => $this->repository->scope(),
'error' => '流程明细镜像尚未完成导入,请稍后重试。',
]);
}
}
public function export(Request $request): StreamedResponse|RedirectResponse
{
if (! $this->repository->ready()) {
return redirect()
->route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'process-details',
])
->withErrors(['export' => '流程明细镜像尚未完成导入,暂时不能导出。']);
}
$filters = $this->filters($request);
return response()->streamDownload(function () use ($filters): void {
$stream = fopen('php://output', 'wb');
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, DingProcessDetailMirrorImporter::HEADERS);
$number = 0;
foreach ($this->repository->exportRows($filters) as $row) {
$number++;
fputcsv($stream, [
$number,
(string) ($row['business_id'] ?? ''),
(string) ($row['originator_name'] ?? ''),
(string) ($row['status'] ?? ''),
(string) ($row['document_type'] ?? ''),
(string) ($row['purpose'] ?? ''),
(int) ($row['line_no'] ?? 0),
(string) ($row['account_code'] ?? ''),
(string) ($row['account_name'] ?? ''),
$row['amount'] ?? '',
(string) ($row['bank_account_code'] ?? ''),
(string) ($row['bank_account_name'] ?? ''),
$row['bank_amount'] ?? '',
(string) ($row['payment_method'] ?? ''),
(string) ($row['paid_at'] ?? ''),
]);
}
fclose($stream);
}, '流程明细_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/** @return array<string,string> */
private function filters(Request $request): array
{
$month = trim((string) $request->query('month', ''));
$status = trim((string) $request->query('status', ''));
return [
'month' => preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '',
'document_type' => mb_substr(trim((string) $request->query('document_type', '')), 0, 100),
'status' => mb_substr($status, 0, 20),
'business_id' => mb_substr(trim((string) $request->query('business_id', '')), 0, 32),
'originator_name' => mb_substr(trim((string) $request->query('originator_name', '')), 0, 100),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
];
}
}

View File

@ -0,0 +1,222 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class DingProcessDetailMirrorRepository
{
public const DEFAULT_SCOPE = 'personal_permission';
public const DATASET_PREFIX = 'ding.process_details.';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
public function scope(): string
{
$scope = trim((string) config(
'upstream_mirrors.ding_process_details.scope',
self::DEFAULT_SCOPE,
));
return $scope !== '' ? $scope : self::DEFAULT_SCOPE;
}
public function datasetKey(): string
{
return self::DATASET_PREFIX . $this->scope();
}
public function ready(): bool
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('ding_process_detail_mirror')
|| ! $schema->hasTable('business_mirror_table_states')) {
return false;
}
$state = $this->state();
return $state !== null && ($state['initial_completed_at'] ?? null) !== null;
}
/** @return array<string,mixed>|null */
public function state(): ?array
{
if (! Schema::connection($this->connection->getName())->hasTable('business_mirror_table_states')) {
return null;
}
$row = $this->connection->table('business_mirror_table_states')
->where('dataset_key', $this->datasetKey())
->first();
return $row === null ? null : (array) $row;
}
/**
* @param array<string,string> $filters
* @return array{
* data:list<object>,
* total:int,
* summary:array{rows:int,approvals:int,amount_total:float,bank_amount_total:float},
* status:string,
* last_synced_at:?string,
* scope:string
* }
*/
public function paginate(array $filters, int $page, int $perPage): array
{
if (! $this->ready()) {
throw new RuntimeException('ding_process_detail_mirror_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->filteredQuery($filters);
$total = (clone $query)->count();
$summary = [
'rows' => $total,
'approvals' => (clone $query)->distinct()->count('business_id'),
'amount_total' => (float) ((clone $query)->sum('amount') ?? 0),
'bank_amount_total' => (float) ((clone $query)->sum('bank_amount') ?? 0),
];
$data = $query
->orderByDesc('business_month')
->orderByDesc('business_id')
->orderBy('line_no')
->forPage($page, $perPage)
->get()
->all();
$state = $this->state();
return [
'data' => $data,
'total' => $total,
'summary' => $summary,
'status' => (string) ($state['status'] ?? 'unknown'),
'last_synced_at' => isset($state['last_succeeded_at'])
? (string) $state['last_succeeded_at']
: null,
'scope' => $this->scope(),
];
}
/** @return array{months:list<string>,document_types:list<string>,statuses:list<string>} */
public function filterOptions(): array
{
if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) {
return ['months' => [], 'document_types' => [], 'statuses' => []];
}
$base = $this->connection->table('ding_process_detail_mirror')
->where('source_scope', $this->scope());
return [
'months' => (clone $base)
->whereNotNull('business_month')
->where('business_month', '!=', '')
->distinct()
->orderByDesc('business_month')
->pluck('business_month')
->map(static function (mixed $value): string {
$month = (string) $value;
return preg_match('/^\d{6}$/', $month) === 1
? substr($month, 0, 4) . '-' . substr($month, 4, 2)
: $month;
})
->all(),
'document_types' => (clone $base)
->whereNotNull('document_type')
->where('document_type', '!=', '')
->distinct()
->orderBy('document_type')
->pluck('document_type')
->map(static fn (mixed $value): string => (string) $value)
->all(),
'statuses' => (clone $base)
->whereNotNull('status')
->where('status', '!=', '')
->distinct()
->orderBy('status')
->pluck('status')
->map(static fn (mixed $value): string => (string) $value)
->all(),
];
}
/** @param array<string,string> $filters */
public function exportCount(array $filters): int
{
if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) {
return 0;
}
return (int) $this->filteredQuery($filters)->count();
}
/**
* @param array<string,string> $filters
* @return \Generator<int,array<string,mixed>>
*/
public function exportRows(array $filters): \Generator
{
$query = $this->filteredQuery($filters)
->orderByDesc('business_month')
->orderByDesc('business_id')
->orderBy('line_no');
foreach ($query->cursor() as $row) {
yield (array) $row;
}
}
/** @param array<string,string> $filters */
private function filteredQuery(array $filters): Builder
{
$query = $this->connection->table('ding_process_detail_mirror')
->where('source_scope', $this->scope());
$month = trim((string) ($filters['month'] ?? ''));
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
$query->where('business_month', str_replace('-', '', $month));
}
foreach ([
'document_type' => 'document_type',
'status' => 'status',
] as $filter => $column) {
$value = trim((string) ($filters[$filter] ?? ''));
if ($value !== '') {
$query->where($column, $value);
}
}
foreach ([
'business_id' => 'business_id',
'originator_name' => 'originator_name',
'keyword' => 'purpose',
] as $filter => $column) {
$value = mb_substr(trim((string) ($filters[$filter] ?? '')), 0, 100);
if ($value !== '') {
$query->where($column, 'like', '%' . $value . '%');
}
}
return $query;
}
}

View File

@ -1,7 +1,25 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\ProcessDetails;
Route::admin('ecommerce-finance', function () {
Route::view('/', 'ecommerce-finance::index')->name('index');
});
// 通用 workspace/{section}/{page} 路由在 App\Providers\Route::boot() 中注册;
// 模块路由在 register 阶段先注册,因此这里的精确路由会优先命中。沿用
// business.pages.show 名称,侧边栏仍能生成与通用页面一致的可访问 URL。
Route::admin('ecommerce-finance', function (): void {
Route::get('workspace/financial-operations/process-details', [ProcessDetails::class, 'show'])
->middleware('business.page.viewer')
->defaults('section', 'financial-operations')
->defaults('page', 'process-details')
->name('business.pages.show');
Route::get('workspace/financial-operations/process-details/export', [ProcessDetails::class, 'export'])
->middleware('business.page.viewer')
->defaults('section', 'financial-operations')
->defaults('page', 'process-details')
->name('business.process-details.export');
}, ['prefix' => '', 'as' => '']);

View File

@ -0,0 +1,282 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use RuntimeException;
class DingProcessDetailMirrorImporter
{
public const DATASET_PREFIX = 'ding.process_details.';
public const DEFAULT_SCOPE = 'personal_permission';
public const HEADERS = [
'序号',
'审批编号',
'发起人',
'状态',
'单据类型',
'用途/摘要',
'单据内行号',
'科目编码',
'科目名称',
'金额(元)',
'银行科目编码',
'银行科目名称',
'银行付款金额',
'付款方式',
'打款时间',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
/**
* @return array{status:string,rows:int,invalid:int,placeholders:int,duplicates:int,scope:string,dataset_key:string}
*/
public function import(
string $path,
string $scope = self::DEFAULT_SCOPE,
bool $replace = false,
): array {
$scope = trim($scope) !== '' ? trim($scope) : self::DEFAULT_SCOPE;
if (! is_file($path)) {
throw new RuntimeException('流程明细 CSV 文件不存在:' . $path);
}
[$rows, $invalid, $placeholders, $duplicates] = $this->readCsv($path, $scope);
if ($rows === []) {
throw new RuntimeException('流程明细 CSV 没有可导入的数据。');
}
$now = now();
$datasetKey = self::DATASET_PREFIX . $scope;
$generation = (string) Str::uuid();
$upserted = 0;
$this->connection->transaction(function () use (
$rows,
$replace,
$scope,
$now,
$generation,
&$upserted,
): void {
if ($replace) {
$this->connection->table('ding_process_detail_mirror')
->where('source_scope', $scope)
->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table('ding_process_detail_mirror')->upsert(
$chunk,
['business_id', 'line_no'],
[
'business_month',
'originator_name',
'status',
'document_type',
'purpose',
'account_code',
'account_name',
'amount',
'bank_account_code',
'bank_account_name',
'bank_amount',
'payment_method',
'paid_at',
'source_scope',
'synced_at',
'updated_at',
],
);
$upserted += count($chunk);
}
});
$rowCount = (int) $this->connection->table('ding_process_detail_mirror')
->where('source_scope', $scope)
->count();
$values = [
'source_key' => 'dingtalk_oa',
'source_object' => $scope === self::DEFAULT_SCOPE
? 'oa approval detail (personal permission scope)'
: 'oa approval detail (' . $scope . ')',
'target_table' => 'ding_process_detail_mirror',
'status' => 'succeeded',
'run_mode' => 'full',
'generation' => $generation,
'cursor' => null,
'high_watermark' => null,
'window_started_at' => null,
'window_ends_at' => null,
'initial_completed_at' => $now,
'last_full_succeeded_at' => $now,
'last_attempted_at' => $now,
'last_succeeded_at' => $now,
'last_upserted_rows' => $upserted,
'mirror_row_count' => $rowCount,
'last_error_code' => null,
'updated_at' => $now,
];
$this->connection->table('business_mirror_table_states')->updateOrInsert(
['dataset_key' => $datasetKey],
$values + ['created_at' => $now],
);
return [
'status' => 'succeeded',
'rows' => $upserted,
'invalid' => $invalid,
'placeholders' => $placeholders,
'duplicates' => $duplicates,
'scope' => $scope,
'dataset_key' => $datasetKey,
];
}
/**
* @return array{0:list<array<string,mixed>>,1:int,2:int,3:int}
*/
private function readCsv(string $path, string $scope): array
{
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('流程明细 CSV 无法打开:' . $path);
}
try {
$header = fgetcsv($handle);
if ($header === false) {
throw new RuntimeException('流程明细 CSV 缺少表头。');
}
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]);
$header = array_map(static fn (mixed $value): string => trim((string) $value), $header);
$missing = array_values(array_diff(self::HEADERS, $header));
if ($missing !== []) {
throw new RuntimeException('流程明细 CSV 缺少列:' . implode('、', $missing));
}
$index = array_flip($header);
$rows = [];
$qualities = [];
$invalid = 0;
$placeholders = 0;
$duplicates = 0;
$now = now();
while (($data = fgetcsv($handle)) !== false) {
if ($data === [null] || count(array_filter(
$data,
static fn (mixed $value): bool => trim((string) $value) !== '',
)) === 0) {
continue;
}
$businessId = trim((string) ($data[$index['审批编号']] ?? ''));
$lineNo = trim((string) ($data[$index['单据内行号']] ?? ''));
if ($businessId === '' || preg_match('/^\d+$/', $lineNo) !== 1) {
$invalid++;
continue;
}
$accountCode = trim((string) ($data[$index['科目编码']] ?? ''));
$accountName = trim((string) ($data[$index['科目名称']] ?? ''));
$amountRaw = trim((string) ($data[$index['金额(元)']] ?? ''));
// 历史导入遗留的占位行:用途/科目/银行字段全部是 251,金额为空。
if ($accountCode === '251' && $accountName === '251' && $amountRaw === '') {
$placeholders++;
continue;
}
$key = $businessId . '|' . (int) $lineNo;
$candidate = [
'business_id' => mb_substr($businessId, 0, 32),
'line_no' => (int) $lineNo,
'business_month' => preg_match('/^\d{6}/', $businessId) === 1
? substr($businessId, 0, 6)
: '',
'originator_name' => mb_substr(trim((string) ($data[$index['发起人']] ?? '')), 0, 100),
'status' => mb_substr(trim((string) ($data[$index['状态']] ?? '')), 0, 20),
'document_type' => mb_substr(trim((string) ($data[$index['单据类型']] ?? '')), 0, 100),
'purpose' => trim((string) ($data[$index['用途/摘要']] ?? '')),
'account_code' => mb_substr($accountCode, 0, 64),
'account_name' => mb_substr($accountName, 0, 255),
'amount' => $this->decimal($data[$index['金额(元)']] ?? null),
'bank_account_code' => mb_substr(trim((string) ($data[$index['银行科目编码']] ?? '')), 0, 255),
'bank_account_name' => mb_substr(trim((string) ($data[$index['银行科目名称']] ?? '')), 0, 255),
'bank_amount' => $this->decimal($data[$index['银行付款金额']] ?? null),
'payment_method' => mb_substr(trim((string) ($data[$index['付款方式']] ?? '')), 0, 50),
'paid_at' => mb_substr(trim((string) ($data[$index['打款时间']] ?? '')), 0, 50),
'source_scope' => $scope,
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$quality = $this->quality($candidate);
if (isset($qualities[$key])) {
$duplicates++;
if ($quality < $qualities[$key]) {
continue;
}
}
$rows[$key] = $candidate;
$qualities[$key] = $quality;
}
return [array_values($rows), $invalid, $placeholders, $duplicates];
} finally {
fclose($handle);
}
}
private function decimal(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '' || ! is_numeric($value)) {
return null;
}
return number_format((float) $value, 2, '.', '');
}
/** @param array<string,mixed> $row */
private function quality(array $row): int
{
$score = 0;
$score += trim((string) ($row['purpose'] ?? '')) !== ''
&& trim((string) ($row['purpose'] ?? '')) !== '251' ? 1 : 0;
$score += trim((string) ($row['account_name'] ?? '')) !== ''
&& trim((string) ($row['account_name'] ?? '')) !== '251' ? 1 : 0;
$score += is_numeric($row['amount'] ?? null) && (float) $row['amount'] !== 0.0 ? 4 : 0;
$score += trim((string) ($row['bank_account_code'] ?? '')) !== ''
&& trim((string) ($row['bank_account_code'] ?? '')) !== '251' ? 2 : 0;
$score += is_numeric($row['bank_amount'] ?? null) && (float) $row['bank_amount'] !== 0.0 ? 2 : 0;
$score += trim((string) ($row['paid_at'] ?? '')) !== ''
&& trim((string) ($row['paid_at'] ?? '')) !== '251' ? 1 : 0;
return $score;
}
}

View File

@ -0,0 +1,452 @@
@php
$rows = $rows ?? null;
$filters = $filters ?? [];
$summary = $summary ?? [];
$months = $months ?? [];
$documentTypes = $documentTypes ?? [];
$statuses = $statuses ?? [];
$error = $error ?? null;
$scope = $scope ?? 'personal_permission';
$scopeLabel = $scope === 'personal_permission' ? '当前账号权限范围' : $scope;
$money = static fn (mixed $value): string => $value === null || $value === ''
? '-'
: number_format((float) $value, 2);
$text = static fn (mixed $value): string => $value === null || trim((string) $value) === ''
? '-'
: (string) $value;
$statusClass = static fn (string $status): string => match ($status) {
'已完成' => 'is-success',
'审批中' => 'is-warning',
'已终止', '已撤销' => 'is-danger',
default => 'is-neutral',
};
$pageUrl = route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'process-details',
]);
$exportUrl = route('business.process-details.export', array_filter(
$filters,
static fn (mixed $value): bool => $value !== '' && $value !== null,
));
@endphp
<x-layouts.admin>
<x-slot name="title">
流程明细
</x-slot>
<x-slot name="content">
<style>
.process-details-page {
color: var(--vip-content-ink, #171717);
font-size: .8125rem;
}
.process-details-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .4rem .9rem;
margin-bottom: .85rem;
color: var(--vip-content-muted, #595959);
font-size: .75rem;
line-height: 1.6;
}
.process-details-meta strong {
color: var(--vip-content-ink, #171717);
font-weight: 600;
}
.process-details-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);
}
.process-details-field {
display: flex;
flex-direction: column;
gap: .35rem;
min-width: 10.5rem;
}
.process-details-field.is-wide {
min-width: 13.5rem;
}
.process-details-field label {
color: var(--vip-content-muted, #595959);
font-size: .75rem;
font-weight: 600;
}
.process-details-field input,
.process-details-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);
font: inherit;
}
.process-details-field input:focus,
.process-details-field select:focus {
border-color: var(--vip-content-accent-dark, #856632);
outline: none;
box-shadow: 0 0 0 3px rgba(133, 102, 50, .2);
}
.process-details-actions {
display: flex;
flex-wrap: wrap;
gap: .5rem;
}
.process-details-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);
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.process-details-button:hover {
border-color: var(--vip-content-accent, #B9954F);
color: var(--vip-content-accent-dark, #856632);
text-decoration: none;
}
.process-details-button.is-primary {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-dark, #856632);
color: #fff;
}
.process-details-button.is-primary:hover {
background: #6A5129;
color: #fff;
}
.process-details-alert {
padding: .75rem 1rem;
margin-bottom: 1rem;
border: 1px solid var(--vip-danger, #B80000);
border-radius: .5rem;
background: #fff5f5;
color: var(--vip-danger, #B80000);
}
.process-details-summary {
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
padding: .8rem 1rem;
margin-bottom: 1rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: .5rem;
background: var(--vip-content-accent-soft, #F7F0DE);
}
.process-details-summary .item {
display: flex;
flex-direction: column;
gap: .15rem;
}
.process-details-summary .item span {
color: var(--vip-content-muted, #595959);
font-size: .7rem;
}
.process-details-summary .item b {
font-size: .95rem;
font-variant-numeric: tabular-nums;
}
.process-details-note {
margin-bottom: .75rem;
color: var(--vip-content-muted, #595959);
font-size: .75rem;
line-height: 1.6;
}
.process-details-table-wrap {
overflow: auto;
max-height: min(68vh, 46rem);
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: .5rem;
background: #fff;
}
.process-details-table {
width: 100%;
min-width: 112rem;
border-collapse: separate;
border-spacing: 0;
font-size: .78rem;
}
.process-details-table th,
.process-details-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: middle;
}
.process-details-table th {
position: sticky;
top: 0;
z-index: 2;
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-ink, #171717);
font-size: .75rem;
font-weight: 700;
text-align: left;
}
.process-details-table tr:hover td {
background: #fcfaf4;
}
.process-details-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.process-details-table .center {
text-align: center;
}
.process-details-table .wide {
max-width: 18rem;
overflow: hidden;
text-overflow: ellipsis;
}
.process-details-table .wide:hover {
overflow: visible;
white-space: normal;
word-break: break-all;
}
.process-details-status {
display: inline-flex;
align-items: center;
padding: .15rem .5rem;
border-radius: 999px;
font-size: .7rem;
font-weight: 600;
}
.process-details-status.is-success {
background: rgba(83, 121, 62, .12);
color: var(--vip-success, #53793E);
}
.process-details-status.is-warning {
background: rgba(147, 95, 7, .12);
color: var(--vip-warning, #935F07);
}
.process-details-status.is-danger {
background: rgba(184, 0, 0, .09);
color: var(--vip-danger, #B80000);
}
.process-details-status.is-neutral {
background: #f1f1f1;
color: var(--vip-content-muted, #595959);
}
.process-details-empty {
padding: 4rem 1rem;
text-align: center;
color: var(--vip-content-muted, #595959);
white-space: normal;
}
.process-details-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: .75rem;
padding: .8rem 1rem;
color: var(--vip-content-muted, #595959);
font-size: .75rem;
}
.process-details-footer .pagination {
margin: 0;
}
@media (max-width: 900px) {
.process-details-toolbar {
align-items: stretch;
}
.process-details-field,
.process-details-field.is-wide {
min-width: 100%;
}
.process-details-actions {
width: 100%;
}
}
</style>
<div class="process-details-page">
<div class="process-details-meta">
<span>数据范围:<strong>{{ $scopeLabel }}</strong></span>
<span>镜像状态:<strong>{{ $mirrorStatus ?? 'unknown' }}</strong></span>
@if (! empty($mirrorSyncedAt))
<span>最近同步:<strong>{{ $mirrorSyncedAt }}</strong></span>
@endif
</div>
@if ($error)
<div class="process-details-alert" role="alert">{{ $error }}</div>
@endif
<form method="GET" action="{{ $pageUrl }}" class="process-details-toolbar" aria-label="流程明细筛选">
<div class="process-details-field">
<label for="process-details-month">月份</label>
<input id="process-details-month" type="month" name="month" value="{{ $filters['month'] ?? '' }}">
</div>
<div class="process-details-field">
<label for="process-details-type">单据类型</label>
<select id="process-details-type" name="document_type">
<option value="">全部类型</option>
@foreach ($documentTypes as $option)
<option value="{{ $option }}" @selected(($filters['document_type'] ?? '') === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="process-details-field">
<label for="process-details-status">状态</label>
<select id="process-details-status" name="status">
<option value="">全部状态</option>
@foreach ($statuses as $option)
<option value="{{ $option }}" @selected(($filters['status'] ?? '') === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="process-details-field is-wide">
<label for="process-details-business-id">审批编号</label>
<input id="process-details-business-id" type="search" name="business_id" value="{{ $filters['business_id'] ?? '' }}" placeholder="支持包含查询">
</div>
<div class="process-details-field">
<label for="process-details-originator">发起人</label>
<input id="process-details-originator" type="search" name="originator_name" value="{{ $filters['originator_name'] ?? '' }}" placeholder="支持包含查询">
</div>
<div class="process-details-field is-wide">
<label for="process-details-keyword">用途/摘要</label>
<input id="process-details-keyword" type="search" name="keyword" value="{{ $filters['keyword'] ?? '' }}" placeholder="支持包含查询">
</div>
<div class="process-details-actions">
<button class="process-details-button is-primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>查询
</button>
<a class="process-details-button" href="{{ $pageUrl }}">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>重置
</a>
<a class="process-details-button" href="{{ $exportUrl }}" title="导出当前筛选结果(CSV,包含全部匹配行)">
<span class="material-icons-outlined" aria-hidden="true">download</span>导出
</a>
</div>
</form>
<div class="process-details-summary">
<div class="item"><span>明细行数</span><b>{{ number_format((int) ($summary['rows'] ?? 0)) }}</b></div>
<div class="item"><span>审批单数</span><b>{{ number_format((int) ($summary['approvals'] ?? 0)) }}</b></div>
<div class="item"><span>金额合计</span><b>{{ $money($summary['amount_total'] ?? null) }}</b></div>
<div class="item"><span>银行付款合计</span><b>{{ $money($summary['bank_amount_total'] ?? null) }}</b></div>
</div>
<p class="process-details-note">
当前展示的是当前账号权限范围内可读取的钉钉审批流程明细;审批中的金额和行项目仍可能随流程变化。
企业级全量数据待领导开通 OA 权限后切换。
</p>
<div class="process-details-table-wrap">
<table class="process-details-table">
<thead>
<tr>
<th>序号</th>
<th>审批编号</th>
<th>发起人</th>
<th>状态</th>
<th>单据类型</th>
<th>用途/摘要</th>
<th class="center">单据内行号</th>
<th>科目编码</th>
<th>科目名称</th>
<th class="num">金额(元)</th>
<th>银行科目编码</th>
<th>银行科目名称</th>
<th class="num">银行付款金额</th>
<th>付款方式</th>
<th>打款时间</th>
</tr>
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td class="num">{{ ($rows->currentPage() - 1) * $rows->perPage() + $loop->iteration }}</td>
<td>{{ $row->business_id }}</td>
<td>{{ $text($row->originator_name) }}</td>
<td class="center">
<span class="process-details-status {{ $statusClass((string) $row->status) }}">{{ $text($row->status) }}</span>
</td>
<td>{{ $text($row->document_type) }}</td>
<td class="wide" title="{{ $row->purpose }}">{{ $text($row->purpose) }}</td>
<td class="center">{{ $row->line_no }}</td>
<td>{{ $text($row->account_code) }}</td>
<td class="wide" title="{{ $row->account_name }}">{{ $text($row->account_name) }}</td>
<td class="num">{{ $money($row->amount) }}</td>
<td>{{ $text($row->bank_account_code) }}</td>
<td class="wide" title="{{ $row->bank_account_name }}">{{ $text($row->bank_account_name) }}</td>
<td class="num">{{ $money($row->bank_amount) }}</td>
<td>{{ $text($row->payment_method) }}</td>
<td>{{ $text($row->paid_at) }}</td>
</tr>
@empty
<tr>
<td colspan="15" class="process-details-empty">
当前筛选条件没有流程明细。可以调整月份、单据类型、状态或关键词后重试。
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="process-details-footer">
<span>
第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,共 {{ number_format($rows->total()) }} 行
</span>
<div>{{ $rows->links() }}</div>
</div>
</div>
</x-slot>
</x-layouts.admin>

View File

@ -0,0 +1,133 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\DingProcessDetailMirrorRepository;
use RuntimeException;
use Tests\Feature\FeatureTestCase;
class ProcessDetailsPageTest extends FeatureTestCase
{
public function test_page_renders_process_detail_rows_and_filters(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingProcessDetailMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->andReturn([
'data' => [
(object) [
'business_id' => '202601011200000000001',
'line_no' => 1,
'originator_name' => '张三',
'status' => '已完成',
'document_type' => '公摊日常报销',
'purpose' => '办公用品采购',
'account_code' => '5602.04',
'account_name' => '管理费用_办公费',
'amount' => 100.00,
'bank_account_code' => '1002.01.001',
'bank_account_name' => '华夏银行-测试',
'bank_amount' => 100.00,
'payment_method' => '银行卡',
'paid_at' => '2026-01-02 00:00',
],
],
'total' => 1,
'summary' => [
'rows' => 1,
'approvals' => 1,
'amount_total' => 100.0,
'bank_amount_total' => 100.0,
],
'status' => 'succeeded',
'last_synced_at' => '2026-10-26 15:00:00',
'scope' => 'personal_permission',
]);
$repository->shouldReceive('filterOptions')->once()->andReturn([
'months' => ['2026-01'],
'document_types' => ['公摊日常报销'],
'statuses' => ['已完成'],
]);
$this->app->instance(DingProcessDetailMirrorRepository::class, $repository);
$financeSection = collect(\App\Utilities\BusinessNavigation::all())
->firstWhere('slug', 'financial-operations');
$this->assertContains(
'process-details',
array_column($financeSection['pages'] ?? [], 'slug'),
);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'process-details',
]))->assertOk()
->assertSee('流程明细')
->assertSee('审批编号')
->assertSee('发起人')
->assertSee('单据类型')
->assertSee('银行付款金额')
->assertSee('202601011200000000001')
->assertSee('张三')
->assertSee('管理费用_办公费')
->assertSee('100.00')
->assertSee('当前账号权限范围')
->assertSee('导出');
}
public function test_page_stays_readable_when_mirror_is_not_ready(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingProcessDetailMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->andThrow(new RuntimeException('not ready'));
$repository->shouldReceive('scope')->once()->andReturn('personal_permission');
$this->app->instance(DingProcessDetailMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'process-details',
]))->assertOk()
->assertSee('流程明细镜像尚未完成导入')
->assertSee('当前账号权限范围');
}
public function test_export_streams_filtered_csv(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingProcessDetailMirrorRepository::class);
$repository->shouldReceive('ready')->once()->andReturn(true);
$repository->shouldReceive('exportRows')->once()->andReturnUsing(function (): \Generator {
yield [
'business_id' => '202601011200000000001',
'originator_name' => '张三',
'status' => '已完成',
'document_type' => '公摊日常报销',
'purpose' => '办公用品采购',
'line_no' => 1,
'account_code' => '5602.04',
'account_name' => '管理费用_办公费',
'amount' => '100.00',
'bank_account_code' => '1002.01.001',
'bank_account_name' => '华夏银行-测试',
'bank_amount' => '100.00',
'payment_method' => '银行卡',
'paid_at' => '2026-01-02 00:00',
];
});
$this->app->instance(DingProcessDetailMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.process-details.export', [
'status' => '已完成',
]));
$response->assertOk();
$content = $response->streamedContent();
$this->assertStringContainsString('审批编号', $content);
$this->assertStringContainsString('202601011200000000001', $content);
$this->assertStringContainsString('张三', $content);
$this->assertStringContainsString('管理费用_办公费', $content);
}
}

View File

@ -0,0 +1,271 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorImporter;
use RuntimeException;
use Tests\TestCase;
class ImportDingProcessDetailsTest extends TestCase
{
private string $fixture;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.ding_process_details.scope' => 'personal_permission',
]);
DB::purge('business_mirror');
$this->createMirrorTables();
$this->fixture = base_path('tests/Fixtures/ding_process_details_sample.csv');
}
protected function tearDown(): void
{
foreach ($this->tempFiles as $file) {
if (is_file($file)) {
unlink($file);
}
}
parent::tearDown();
}
public function test_imports_csv_and_publishes_mirror_state(): void
{
$result = app(DingProcessDetailMirrorImporter::class)
->import($this->fixture, 'personal_permission', true);
$this->assertSame('succeeded', $result['status']);
$this->assertSame(4, $result['rows']);
$this->assertSame(0, $result['invalid']);
$this->assertSame(0, $result['placeholders']);
$this->assertSame(4, DB::connection('business_mirror')
->table('ding_process_detail_mirror')->count());
$row = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202601011200000000001')
->where('line_no', 1)
->first();
$this->assertNotNull($row);
$this->assertSame('张三', $row->originator_name);
$this->assertSame('已完成', $row->status);
$this->assertSame('公摊日常报销', $row->document_type);
$this->assertSame('5602.04', $row->account_code);
$this->assertSame('管理费用_办公费', $row->account_name);
$this->assertEqualsWithDelta(100.0, (float) $row->amount, 0.001);
$this->assertSame('202601', $row->business_month);
$this->assertSame('personal_permission', $row->source_scope);
$state = DB::connection('business_mirror')
->table('business_mirror_table_states')
->where('dataset_key', 'ding.process_details.personal_permission')
->first();
$this->assertNotNull($state);
$this->assertSame('succeeded', $state->status);
$this->assertSame('ding_process_detail_mirror', $state->target_table);
$this->assertNotNull($state->initial_completed_at);
$this->assertSame(4, (int) $state->mirror_row_count);
}
public function test_import_is_idempotent_by_business_id_and_line_number(): void
{
$importer = app(DingProcessDetailMirrorImporter::class);
$importer->import($this->fixture, 'personal_permission', true);
$importer->import($this->fixture, 'personal_permission', false);
$this->assertSame(4, DB::connection('business_mirror')
->table('ding_process_detail_mirror')->count());
}
public function test_replace_removes_rows_that_are_not_in_the_new_file(): void
{
$importer = app(DingProcessDetailMirrorImporter::class);
$importer->import($this->fixture, 'personal_permission', true);
$replacement = $this->makeCsv([[
'1',
'202603011200000000003',
'王五',
'已完成',
'店铺日常报销',
'替换后的流程',
'1',
'5601.01',
'销售费用',
'10.00',
'1002.01.003',
'银行-测试3',
'10.00',
'银行卡',
'2026-03-02 00:00',
]]);
$importer->import($replacement, 'personal_permission', true);
$this->assertSame(1, DB::connection('business_mirror')
->table('ding_process_detail_mirror')->count());
$this->assertSame(
'202603011200000000003',
(string) DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->value('business_id'),
);
}
public function test_missing_headers_does_not_delete_existing_rows(): void
{
$importer = app(DingProcessDetailMirrorImporter::class);
$importer->import($this->fixture, 'personal_permission', true);
$path = tempnam(sys_get_temp_dir(), 'ding-process-bad-');
file_put_contents($path, "序号,审批编号\n1,202603011200000000003\n");
$this->tempFiles[] = $path;
try {
$importer->import($path, 'personal_permission', true);
$this->fail('缺少表头时应抛出异常。');
} catch (RuntimeException $exception) {
$this->assertStringContainsString('缺少列', $exception->getMessage());
}
$this->assertSame(4, DB::connection('business_mirror')
->table('ding_process_detail_mirror')->count());
}
public function test_placeholder_rows_are_skipped_and_the_better_duplicate_wins(): void
{
$path = $this->makeCsv([
[
'1', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '真实费用',
'1', '5602.04', '管理费用_办公费', '100.00', '1002.01.001', '华夏银行-测试',
'100.00', '银行卡', '2026-04-02 00:00',
],
[
'2', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '251',
'1', '251', '251', '', '251', '251', '251', '251', '251',
],
[
'3', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '251',
'1', '1221.05', '251', '0.00', '1002.01.001', '华夏银行-测试',
'100.00', '银行卡', '2026-04-02 00:00',
],
[
'4', '202604021200000000005', '钱七', '已完成', '店铺日常报销', '251',
'1', '251', '251', '', '251', '251', '251', '251', '251',
],
]);
$result = app(DingProcessDetailMirrorImporter::class)
->import($path, 'personal_permission', true);
$this->assertSame(1, $result['rows']);
$this->assertSame(2, $result['placeholders']);
$this->assertSame(1, $result['duplicates']);
$row = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->first();
$this->assertSame('5602.04', $row->account_code);
$this->assertSame('管理费用_办公费', $row->account_name);
$this->assertEqualsWithDelta(100.0, (float) $row->amount, 0.001);
}
public function test_artisan_command_imports_the_file(): void
{
$this->artisan('ding:import-process-details', [
'file' => $this->fixture,
'--scope' => 'personal_permission',
'--replace' => true,
])->assertSuccessful();
$this->assertSame(4, DB::connection('business_mirror')
->table('ding_process_detail_mirror')->count());
}
private function createMirrorTables(): void
{
Schema::connection('business_mirror')->create(
'business_mirror_table_states',
function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('source_key');
$table->string('source_object');
$table->string('target_table');
$table->string('status')->default('pending');
$table->string('run_mode')->nullable();
$table->string('generation')->nullable();
$table->json('cursor')->nullable();
$table->json('high_watermark')->nullable();
$table->dateTime('window_started_at')->nullable();
$table->dateTime('window_ends_at')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_full_succeeded_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->unsignedBigInteger('last_upserted_rows')->default(0);
$table->unsignedBigInteger('mirror_row_count')->default(0);
$table->string('last_error_code')->nullable();
$table->timestamps();
},
);
Schema::connection('business_mirror')->create(
'ding_process_detail_mirror',
function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('business_id', 32);
$table->unsignedInteger('line_no');
$table->string('business_month', 6)->index();
$table->string('originator_name', 100)->nullable()->index();
$table->string('status', 20)->nullable()->index();
$table->string('document_type', 100)->nullable()->index();
$table->text('purpose')->nullable();
$table->string('account_code', 64)->nullable();
$table->string('account_name', 255)->nullable();
$table->decimal('amount', 18, 2)->nullable();
$table->string('bank_account_code', 255)->nullable();
$table->string('bank_account_name', 255)->nullable();
$table->decimal('bank_amount', 18, 2)->nullable();
$table->string('payment_method', 50)->nullable();
$table->string('paid_at', 50)->nullable();
$table->string('source_scope', 40)->default('personal_permission')->index();
$table->dateTime('synced_at', 6);
$table->timestamps();
$table->unique(['business_id', 'line_no']);
},
);
}
/** @param list<list<string>> $rows */
private function makeCsv(array $rows): string
{
$path = tempnam(sys_get_temp_dir(), 'ding-process-');
$handle = fopen($path, 'wb');
fputcsv($handle, DingProcessDetailMirrorImporter::HEADERS);
foreach ($rows as $row) {
fputcsv($handle, $row);
}
fclose($handle);
$this->tempFiles[] = $path;
return $path;
}
}

View File

@ -0,0 +1,5 @@
序号,审批编号,发起人,状态,单据类型,用途/摘要,单据内行号,科目编码,科目名称,金额(元),银行科目编码,银行科目名称,银行付款金额,付款方式,打款时间
1,202601011200000000001,张三,已完成,公摊日常报销,办公用品采购,1,5602.04,管理费用_办公费,100.00,1002.01.001,华夏银行-测试,100.00,银行卡,2026-01-02 00:00
2,202601011200000000001,张三,已完成,公摊日常报销,交通费,2,5602.06,管理费用_交通费,50.50,1002.01.001,华夏银行-测试,50.50,银行卡,2026-01-02 00:00
3,202602031200000000002,李四,审批中,应付申请流程,供应商货款,1,,,200.00,1002.01.002,银行-测试2,200.00,银行卡,
4,202602031200000000002,李四,审批中,应付申请流程,供应商货款,2,,,300.00,1002.01.002,银行-测试2,300.00,银行卡,
1 序号 审批编号 发起人 状态 单据类型 用途/摘要 单据内行号 科目编码 科目名称 金额(元) 银行科目编码 银行科目名称 银行付款金额 付款方式 打款时间
2 1 202601011200000000001 张三 已完成 公摊日常报销 办公用品采购 1 5602.04 管理费用_办公费 100.00 1002.01.001 华夏银行-测试 100.00 银行卡 2026-01-02 00:00
3 2 202601011200000000001 张三 已完成 公摊日常报销 交通费 2 5602.06 管理费用_交通费 50.50 1002.01.001 华夏银行-测试 50.50 银行卡 2026-01-02 00:00
4 3 202602031200000000002 李四 审批中 应付申请流程 供应商货款 1 200.00 1002.01.002 银行-测试2 200.00 银行卡
5 4 202602031200000000002 李四 审批中 应付申请流程 供应商货款 2 300.00 1002.01.002 银行-测试2 300.00 银行卡

View File

@ -0,0 +1,233 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\DingProcessDetailMirrorRepository;
use RuntimeException;
use Tests\TestCase;
class DingProcessDetailMirrorRepositoryTest extends TestCase
{
private DingProcessDetailMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.ding_process_details.scope' => 'personal_permission',
]);
DB::purge('business_mirror');
$this->createMirrorTables();
$this->seedRows();
$this->repository = app(DingProcessDetailMirrorRepository::class);
}
public function test_paginate_returns_rows_summary_and_filter_options(): void
{
$result = $this->repository->paginate([], 1, 50);
$this->assertSame(4, $result['total']);
$this->assertCount(4, $result['data']);
$this->assertSame(4, $result['summary']['rows']);
$this->assertSame(2, $result['summary']['approvals']);
$this->assertEqualsWithDelta(650.5, $result['summary']['amount_total'], 0.001);
$this->assertEqualsWithDelta(650.5, $result['summary']['bank_amount_total'], 0.001);
$this->assertSame('personal_permission', $result['scope']);
$options = $this->repository->filterOptions();
$this->assertSame(['2026-02', '2026-01'], $options['months']);
$this->assertContains('公摊日常报销', $options['document_types']);
$this->assertContains('应付申请流程', $options['document_types']);
$this->assertContains('已完成', $options['statuses']);
$this->assertContains('审批中', $options['statuses']);
}
public function test_paginate_applies_month_status_and_keyword_filters(): void
{
$this->assertSame(
2,
$this->repository->paginate(['month' => '2026-01'], 1, 50)['total'],
);
$this->assertSame(
2,
$this->repository->paginate(['status' => '审批中'], 1, 50)['total'],
);
$this->assertSame(
1,
$this->repository->paginate(['keyword' => '办公'], 1, 50)['total'],
);
}
public function test_paginate_applies_document_type_originator_and_business_id_filters(): void
{
$this->assertSame(
2,
$this->repository->paginate([
'document_type' => '应付申请流程',
'originator_name' => '李四',
], 1, 50)['total'],
);
$this->assertSame(
2,
$this->repository->paginate(['business_id' => '0002'], 1, 50)['total'],
);
}
public function test_export_rows_returns_every_filtered_row(): void
{
$rows = iterator_to_array($this->repository->exportRows(['status' => '已完成']));
$this->assertCount(2, $rows);
$this->assertSame('202601011200000000001', (string) $rows[0]['business_id']);
}
public function test_not_ready_when_state_is_missing(): void
{
DB::connection('business_mirror')
->table('business_mirror_table_states')
->where('dataset_key', 'ding.process_details.personal_permission')
->delete();
$this->assertFalse($this->repository->ready());
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('ding_process_detail_mirror_not_ready');
$this->repository->paginate([], 1, 50);
}
private function seedRows(): void
{
$now = now();
$rows = [
$this->row('202601011200000000001', 1, '202601', '张三', '已完成', '公摊日常报销', '办公用品采购', 100.00),
$this->row('202601011200000000001', 2, '202601', '张三', '已完成', '公摊日常报销', '交通费', 50.50),
$this->row('202602031200000000002', 1, '202602', '李四', '审批中', '应付申请流程', '供应商货款', 200.00),
$this->row('202602031200000000002', 2, '202602', '李四', '审批中', '应付申请流程', '供应商货款', 300.00),
];
DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->insert($rows);
DB::connection('business_mirror')
->table('business_mirror_table_states')
->insert([
'dataset_key' => 'ding.process_details.personal_permission',
'source_key' => 'dingtalk_oa',
'source_object' => 'oa approval detail (personal permission scope)',
'target_table' => 'ding_process_detail_mirror',
'status' => 'succeeded',
'run_mode' => 'full',
'generation' => '00000000-0000-0000-0000-000000000001',
'initial_completed_at' => $now,
'last_full_succeeded_at' => $now,
'last_attempted_at' => $now,
'last_succeeded_at' => $now,
'last_upserted_rows' => 4,
'mirror_row_count' => 4,
'created_at' => $now,
'updated_at' => $now,
]);
}
/** @return array<string,mixed> */
private function row(
string $businessId,
int $lineNo,
string $month,
string $originator,
string $status,
string $documentType,
string $purpose,
float $amount,
): array {
$now = now();
return [
'business_id' => $businessId,
'line_no' => $lineNo,
'business_month' => $month,
'originator_name' => $originator,
'status' => $status,
'document_type' => $documentType,
'purpose' => $purpose,
'account_code' => '5602.04',
'account_name' => '管理费用_办公费',
'amount' => $amount,
'bank_account_code' => '1002.01.001',
'bank_account_name' => '华夏银行-测试',
'bank_amount' => $amount,
'payment_method' => '银行卡',
'paid_at' => '2026-01-02 00:00',
'source_scope' => 'personal_permission',
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
private function createMirrorTables(): void
{
Schema::connection('business_mirror')->create(
'business_mirror_table_states',
function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('source_key');
$table->string('source_object');
$table->string('target_table');
$table->string('status')->default('pending');
$table->string('run_mode')->nullable();
$table->string('generation')->nullable();
$table->json('cursor')->nullable();
$table->json('high_watermark')->nullable();
$table->dateTime('window_started_at')->nullable();
$table->dateTime('window_ends_at')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_full_succeeded_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->unsignedBigInteger('last_upserted_rows')->default(0);
$table->unsignedBigInteger('mirror_row_count')->default(0);
$table->string('last_error_code')->nullable();
$table->timestamps();
},
);
Schema::connection('business_mirror')->create(
'ding_process_detail_mirror',
function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('business_id', 32);
$table->unsignedInteger('line_no');
$table->string('business_month', 6)->index();
$table->string('originator_name', 100)->nullable()->index();
$table->string('status', 20)->nullable()->index();
$table->string('document_type', 100)->nullable()->index();
$table->text('purpose')->nullable();
$table->string('account_code', 64)->nullable();
$table->string('account_name', 255)->nullable();
$table->decimal('amount', 18, 2)->nullable();
$table->string('bank_account_code', 255)->nullable();
$table->string('bank_account_name', 255)->nullable();
$table->decimal('bank_amount', 18, 2)->nullable();
$table->string('payment_method', 50)->nullable();
$table->string('paid_at', 50)->nullable();
$table->string('source_scope', 40)->default('personal_permission')->index();
$table->dateTime('synced_at', 6);
$table->timestamps();
$table->unique(['business_id', 'line_no']);
},
);
}
}