fix: 对平OA付款月份与费用明细 (#824)
This commit is contained in:
parent
9260549d83
commit
c8a941e085
@ -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' => ['付款合计'],
|
||||
],
|
||||
],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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', '!=', '');
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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')));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user