fix: 对平OA付款月份与费用明细 (#824)

This commit is contained in:
yueyang 2026-11-02 16:54:38 +08:00
parent 9260549d83
commit c8a941e085
6 changed files with 266 additions and 8 deletions

View File

@ -115,12 +115,14 @@ return [
'detail_table' => '收款费用明细',
'detail_amount_fields' => ['收款金额'],
'amount_total_fields' => ['收款合计'],
'payment_table' => '付款明细',
'payment_total_fields' => ['付款合计'],
],
'PROC-C014F61F-F1F8-4F36-93F8-DF2A7EEAC457' => [
'document_type' => '外贸发货费用报销',
'scalar_detail' => true,
'detail_amount_fields' => ['收款金额'],
'payment_table' => '付款明细',
'payment_total_fields' => ['付款合计'],
],
'PROC-1E002C79-C087-4630-B9CE-FAB6DF80FAC5' => [
@ -128,6 +130,7 @@ return [
'detail_table' => '工资明细表',
'detail_amount_fields' => ['付款金额'],
'amount_total_fields' => ['收款合计'],
'payment_table' => '出纳填写工资付款明细',
'payment_total_fields' => ['付款合计'],
],
'PROC-2C1EFBA6-2369-461C-8CCD-05A3664CB8B5' => [
@ -135,6 +138,7 @@ return [
'detail_table' => '收款费用明细',
'detail_amount_fields' => ['收款金额'],
'amount_total_fields' => ['收款合计'],
'payment_table' => '付款明细',
'payment_total_fields' => ['付款合计'],
],
],

View File

@ -0,0 +1,87 @@
<?php
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\DingProcessPaymentLineAllocator;
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')
|| ! $schema->hasTable('ding_process_payment_mirror')
|| ! $schema->hasTable('ding_process_payment_line_mirror')) {
return;
}
$schema->getConnection()->transaction(function (): void {
$database = Schema::connection($this->connection())->getConnection();
$database->table('ding_process_payment_line_mirror')->delete();
$this->backfill($database);
});
}
public function down(): void
{
// 该表是可从审批明细和付款事实重建的派生镜像,无需删除现有数据。
}
private function backfill(ConnectionInterface $database): void
{
$allocator = app(DingProcessPaymentLineAllocator::class);
$now = now();
$scopes = $database->table('ding_process_payment_mirror')
->distinct()
->pluck('source_scope');
foreach ($scopes as $scopeValue) {
$scope = trim((string) $scopeValue);
if ($scope === '') {
continue;
}
$lastId = 0;
do {
$payments = $database->table('ding_process_payment_mirror')
->where('source_scope', $scope)
->where('id', '>', $lastId)
->orderBy('id')
->limit(500)
->get()
->map(static fn (object $row): array => (array) $row)
->all();
if ($payments === []) {
break;
}
$lastId = (int) end($payments)['id'];
$businessIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['business_id'] ?? '')),
$payments,
))));
$details = $database->table('ding_process_detail_mirror')
->where('source_scope', $scope)
->whereIn('business_id', $businessIds)
->get()
->map(static fn (object $row): array => (array) $row)
->all();
$rows = $allocator->allocate($details, $payments, $scope, $now);
foreach (array_chunk($rows, 500) as $chunk) {
$database->table('ding_process_payment_line_mirror')->insert($chunk);
}
} while (count($payments) === 500);
}
}
};

View File

@ -509,6 +509,10 @@ class DingProcessDetailMirrorSynchronizer
'ding_process_detail_mirror',
'account_code',
);
$paymentDateRefreshEnabled = Schema::connection($this->connection->getName())->hasColumn(
'ding_process_detail_mirror',
'paid_at',
);
$remainingAttempts = self::APPROVAL_API_DRAIN_LIMIT - $this->approvalApiAttempts;
if (! $this->approvalApiAvailable || $remainingAttempts <= 0) {
return [];
@ -528,6 +532,7 @@ class DingProcessDetailMirrorSynchronizer
$cashierRefreshEnabled,
$approverRefreshEnabled,
$accountCodeRefreshEnabled,
$paymentDateRefreshEnabled,
$cashierStaleBefore,
): void {
$query->where('payment_source_state', 'missing');
@ -555,6 +560,17 @@ class DingProcessDetailMirrorSynchronizer
->orWhere('account_code', '');
});
}
if ($paymentDateRefreshEnabled) {
// 已完成审批已有付款事实但日期为空,通常来自历史同步配置
// 未读取付款明细表。轮转刷新可自动补回真实打款月份。
$query->orWhere(function ($paymentDate): void {
$paymentDate->where('status', '已完成')
->whereIn('payment_source_state', ['present', 'api', 'missing'])
->where(function ($paidAt): void {
$paidAt->whereNull('paid_at')->orWhere('paid_at', '');
});
});
}
})
->whereNotNull('process_instance_id')
->where('process_instance_id', '!=', '');

View File

@ -43,7 +43,8 @@ final class DingProcessPaymentLineAllocator
$lineNumbers = $this->lineNumbers(
(string) ($payment['sequence_spec'] ?? ''),
array_keys($businessDetails),
$businessDetails,
$amountCents,
);
if ($lineNumbers === []) {
continue;
@ -95,24 +96,41 @@ final class DingProcessPaymentLineAllocator
}
/**
* @param list<int> $availableLines
* @param array<int,array{amount_cents:int}> $businessDetails
* @return list<int>
*/
private function lineNumbers(string $sequenceSpec, array $availableLines): array
private function lineNumbers(
string $sequenceSpec,
array $businessDetails,
int $amountCents,
): array
{
$availableLines = array_map('intval', array_keys($businessDetails));
sort($availableLines);
if ($availableLines === []) {
return [];
}
preg_match_all('/\d+/', $sequenceSpec, $matches);
$requested = array_values(array_unique(array_map('intval', $matches[0] ?? [])));
$matched = array_values(array_intersect($requested, $availableLines));
if ($matched !== []) {
return $matched;
}
if ($requested !== []) {
return [];
$exactLines = [];
foreach ($businessDetails as $lineNo => $detail) {
if ((int) $detail['amount_cents'] === $amountCents) {
$exactLines[] = (int) $lineNo;
}
}
if (count($exactLines) === 1) {
return $exactLines;
}
sort($availableLines);
return $availableLines === [] ? [] : [$availableLines[0]];
// 序号为空或引用了不存在的明细行时,仍按全部明细金额分摊,
// 保证每一笔已付款都能进入对应审批,不因单行数据错误整笔漏计。
return $availableLines;
}
/**

View File

@ -1089,6 +1089,45 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
);
}
public function test_completed_payment_row_with_blank_paid_at_is_queued_for_refresh(): void
{
$now = now();
DB::connection('business_mirror')->table('ding_process_detail_mirror')->insert([
'business_id' => '202610301116000560927',
'process_instance_id' => 'process-payment-date-refresh',
'line_no' => 1,
'business_month' => '202610',
'originator_name' => '周生辰',
'status' => '已完成',
'approval_finished_at' => '2026-11-02 13:03:00',
'payment_source_state' => 'present',
'cashier_status' => 'completed',
'cashier_checked_at' => $now,
'current_approver_name' => '已审批',
'document_type' => '线上代发业务报销',
'purpose' => '10.29种菜报销',
'account_code' => '1221.01',
'account_name' => '其他应收款_运营中心-种菜刷单',
'amount' => 100,
'paid_at' => '',
'source_scope' => 'personal_permission',
'synced_at' => $now->copy()->subHour(),
'created_at' => $now->copy()->subDay(),
'updated_at' => $now->copy()->subHour(),
]);
$method = new \ReflectionMethod(
DingProcessDetailMirrorSynchronizer::class,
'approvalApiFallbackCandidates',
);
$method->setAccessible(true);
$this->assertSame(
['process-payment-date-refresh'],
$method->invoke($this->synchronizer, '', [], 10),
);
}
public function test_incremental_sync_drains_cashier_backlog_before_changed_workflows(): void
{
$connection = DB::connection('business_mirror');

View File

@ -98,4 +98,98 @@ class DingProcessPaymentLineAllocatorTest extends TestCase
Carbon::setTestNow();
}
}
public function test_it_allocates_an_invalid_sequence_to_the_unique_matching_detail(): void
{
$rows = (new DingProcessPaymentLineAllocator())->allocate(
[
['business_id' => 'approval-3', 'line_no' => 1, 'amount' => '100.00'],
['business_id' => 'approval-3', 'line_no' => 2, 'amount' => '200.00'],
],
[[
'business_id' => 'approval-3',
'process_instance_id' => 'process-3',
'payment_no' => 1,
'sequence_spec' => '9',
'amount' => '200.00',
'paid_at' => '2026-10-15 00:00',
]],
'personal_permission',
Carbon::parse('2026-11-02 12:00:00'),
);
$this->assertSame(
[[2, 200.0]],
array_map(
static fn (array $row): array => [
(int) $row['line_no'],
(float) $row['amount'],
],
$rows,
),
);
}
public function test_it_allocates_a_payment_without_a_sequence_across_all_details(): void
{
$rows = (new DingProcessPaymentLineAllocator())->allocate(
[
['business_id' => 'approval-4', 'line_no' => 1, 'amount' => '100.00'],
['business_id' => 'approval-4', 'line_no' => 2, 'amount' => '200.00'],
],
[[
'business_id' => 'approval-4',
'process_instance_id' => 'process-4',
'payment_no' => 1,
'sequence_spec' => '',
'amount' => '300.00',
'paid_at' => '2026-10-15 00:00',
]],
'personal_permission',
Carbon::parse('2026-11-02 12:00:00'),
);
$this->assertSame(
[[1, 100.0], [2, 200.0]],
array_map(
static fn (array $row): array => [
(int) $row['line_no'],
(float) $row['amount'],
],
$rows,
),
);
}
public function test_it_does_not_drop_a_payment_when_the_sequence_and_amount_do_not_match(): void
{
$rows = (new DingProcessPaymentLineAllocator())->allocate(
[
['business_id' => 'approval-5', 'line_no' => 1, 'amount' => '100.00'],
['business_id' => 'approval-5', 'line_no' => 2, 'amount' => '200.00'],
],
[[
'business_id' => 'approval-5',
'process_instance_id' => 'process-5',
'payment_no' => 1,
'sequence_spec' => '9',
'amount' => '150.00',
'paid_at' => '2026-10-15 00:00',
]],
'personal_permission',
Carbon::parse('2026-11-02 12:00:00'),
);
$this->assertSame(
[[1, 50.0], [2, 100.0]],
array_map(
static fn (array $row): array => [
(int) $row['line_no'],
(float) $row['amount'],
],
$rows,
),
);
$this->assertSame(150.0, array_sum(array_column($rows, 'amount')));
}
}