feat: 财务经营报表新增流程明细页面 #608
43
app/Console/Commands/ImportDingProcessDetails.php
Normal file
43
app/Console/Commands/ImportDingProcessDetails.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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'] ?? []);
|
||||
|
||||
19
config/business_navigation_page_extensions.php
Normal file
19
config/business_navigation_page_extensions.php
Normal 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' => '流程明细',
|
||||
],
|
||||
],
|
||||
];
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
139
modules/EcommerceFinance/Http/Controllers/ProcessDetails.php
Normal file
139
modules/EcommerceFinance/Http/Controllers/ProcessDetails.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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' => '']);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
133
tests/Feature/Business/ProcessDetailsPageTest.php
Normal file
133
tests/Feature/Business/ProcessDetailsPageTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
271
tests/Feature/Console/ImportDingProcessDetailsTest.php
Normal file
271
tests/Feature/Console/ImportDingProcessDetailsTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
5
tests/Fixtures/ding_process_details_sample.csv
Normal file
5
tests/Fixtures/ding_process_details_sample.csv
Normal 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,银行卡,
|
||||
|
@ -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']);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user