CaiWuXiTong/tests/Feature/Business/SupplierNegativeAlertDingTalkTest.php
苏木 79fad431df feat: 自动推送负数预警并支持处理截图 (#742)
每日自动推送未处理供应商,增加处理截止时间和截图上传。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-10-28 16:32:21 +08:00

502 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Business;
use App\Http\Controllers\Business\PayablesFinalActions;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\SupplierNegativeAlertDingTalkWorkflow;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Tests\Feature\FeatureTestCase;
class SupplierNegativeAlertDingTalkTest extends FeatureTestCase
{
private const WEBHOOK = 'https://oapi.dingtalk.com/robot/send?access_token=sna-test-token';
private const SECRET = 'SEC-sna-test-secret';
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
public function test_group_robot_configuration_is_encrypted_and_rendered_without_secrets(): void
{
$this->loginAs()
->post(route('business.supplier-negative-alerts.dingtalk-group.save'), [
'dingtalk_webhook' => self::WEBHOOK,
'dingtalk_secret' => self::SECRET,
'dingtalk_group_name' => '财务&采购沟通群',
])
->assertStatus(302)
->assertSessionHas('success', '供应商负数预警群机器人已保存。');
$stored = (string) setting(SupplierNegativeAlertDingTalkWorkflow::SETTING_KEY);
$this->assertStringNotContainsString('sna-test-token', $stored);
$this->assertStringNotContainsString(self::SECRET, $stored);
$this->loginAs()
->get($this->pageUrl())
->assertOk()
->assertSeeText('群机器人已配置')
->assertSeeText('财务&采购沟通群')
->assertDontSeeText('sna-test-token')
->assertDontSeeText(self::SECRET);
}
public function test_group_robot_rejects_untrusted_webhook(): void
{
$this->withExceptionHandling();
$this->loginAs()
->post(route('business.supplier-negative-alerts.dingtalk-group.save'), [
'dingtalk_webhook' => 'https://example.com/robot/send?access_token=unsafe',
'dingtalk_secret' => self::SECRET,
'dingtalk_group_name' => '财务&采购沟通群',
])
->assertStatus(302)
->assertSessionHasErrors('dingtalk_webhook');
}
public function test_send_to_dingtalk_group_and_public_supplier_form_returns_results_to_page(): void
{
$this->seedRows();
$this->bindSupplierManagement();
app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration(
self::WEBHOOK,
self::SECRET,
'财务&采购沟通群',
);
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
Storage::fake('uploads');
$this->loginAs()
->post(route('business.supplier-negative-alerts.send-dingtalk'), [
'purchaser' => '采购甲',
'month' => '2026-09',
])
->assertStatus(302)
->assertSessionHas('success', '已发送至财务&采购沟通群:采购 采购甲,2 家供应商、3 条预警。');
$assignment = DB::table('supplier_negative_alert_assignments')->first();
$this->assertNotNull($assignment);
$this->assertSame('sent', $assignment->status);
$this->assertSame(2, (int) $assignment->supplier_count);
$this->assertSame(3, (int) $assignment->row_count);
$taskUrl = null;
Http::assertSent(function ($request) use (&$taskUrl): bool {
$taskUrl = (string) ($request['actionCard']['singleURL'] ?? '');
return str_contains($request->url(), 'robot/send')
&& str_contains($request->url(), 'timestamp=')
&& str_contains($request->url(), 'sign=')
&& ($request['msgtype'] ?? '') === 'actionCard'
&& str_contains((string) ($request['actionCard']['text'] ?? ''), '采购:**采购甲**')
&& str_contains($taskUrl, '/supplier-negative-alert/task/');
});
preg_match('/\/supplier-negative-alert\/task\/([A-Za-z0-9]{64})$/', (string) $taskUrl, $matches);
$this->assertNotEmpty($matches[1] ?? null);
$token = $matches[1];
$response = $this->get(route('supplier-negative-alert.task', ['token' => $token]))
->assertOk()
->assertSeeText('供应商负数预警处理')
->assertSeeText('三洋木业')
->assertSeeText('久柯家具')
->assertSeeText('最新账期负数合计')
->assertSeeText('2026-09')
->assertSeeText('¥-230.00')
->assertSeeText('最晚处理时间')
->assertSeeText('2026-10-31 18:00')
->assertSeeText('该账期涉及')
->assertSeeText('3 条负数预警')
->assertSee('placeholder="请填写该供应商的核对、沟通或处理结果"', false)
->assertSee('name="screenshots[0][]"', false)
->assertSeeText('处理结果');
$this->assertSame(2, substr_count((string) $response->getContent(), 'name="results[]"'));
$this->post(route('supplier-negative-alert.task.update', ['token' => $token]), [
'results' => [
'三洋木业:已核对,等待供应商补货。',
'久柯家具:已确认负数由历史退款导致。',
],
'screenshots' => [
0 => [UploadedFile::fake()->image('sanyang-result.png', 800, 500)],
1 => [],
],
])
->assertRedirect(route('supplier-negative-alert.task', ['token' => $token]))
->assertSessionHas('negative_alert_success');
$assignment = DB::table('supplier_negative_alert_assignments')->first();
$this->assertSame('completed', $assignment->status);
$this->assertSame(
'2026-09',
DB::table('supplier_negative_alert_results')->where('supplier_name', '三洋木业')->value('period'),
);
$attachment = DB::table('supplier_negative_alert_result_attachments')->first();
$this->assertNotNull($attachment);
Storage::disk('uploads')->assertExists($attachment->path);
$this->get(route('supplier-negative-alert.task.attachment', [
'token' => $token,
'attachment' => $attachment->id,
]))->assertOk();
$this->assertEqualsCanonicalizing(
['久柯家具', '三洋木业'],
DB::table('supplier_negative_alert_results')->orderBy('supplier_name')->pluck('supplier_name')->all(),
);
$this->loginAs()
->get($this->pageUrl(['month' => '2026-09', 'purchaser' => '采购甲']))
->assertOk()
->assertSeeText('三洋木业:已核对,等待供应商补货。')
->assertSeeText('久柯家具:已确认负数由历史退款导致。')
->assertDontSeeText('待采购处理');
}
public function test_expired_task_link_is_unavailable(): void
{
$this->seedRows();
$this->bindSupplierManagement();
app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration(
self::WEBHOOK,
self::SECRET,
'财务&采购沟通群',
);
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$this->loginAs()->post(route('business.supplier-negative-alerts.send-dingtalk'), [
'purchaser' => '采购甲',
'month' => '2026-09',
]);
$taskUrl = '';
Http::assertSent(function ($request) use (&$taskUrl): bool {
$taskUrl = (string) ($request['actionCard']['singleURL'] ?? '');
return $taskUrl !== '';
});
preg_match('/\/([A-Za-z0-9]{64})$/', $taskUrl, $matches);
DB::table('supplier_negative_alert_assignments')->update([
'token_expires_at' => now()->subMinute(),
]);
$this->get(route('supplier-negative-alert.task', ['token' => $matches[1]]))
->assertNotFound()
->assertSeeText('任务链接无效或已过期');
}
public function test_supplier_results_can_be_saved_individually_for_daily_unhandled_filtering(): void
{
$this->seedRows();
$this->bindSupplierManagement();
app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration(
self::WEBHOOK,
self::SECRET,
'财务&采购沟通群',
);
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$this->loginAs()->post(route('business.supplier-negative-alerts.send-dingtalk'), [
'purchaser' => '采购甲',
'month' => '2026-09',
]);
$token = $this->actionCardTokenFromLastMessage();
$this->post(route('supplier-negative-alert.task.update', ['token' => $token]), [
'results' => ['三洋木业:已处理', ''],
])
->assertRedirect(route('supplier-negative-alert.task', ['token' => $token]))
->assertSessionHas('negative_alert_success');
$assignment = DB::table('supplier_negative_alert_assignments')->first();
$this->assertSame('processing', $assignment->status);
$this->assertSame(
['三洋木业'],
app(SupplierNegativeAlertDingTalkWorkflow::class)
->handledSuppliersForPeriod((int) $this->company->id, '2026-09'),
);
$this->post(route('supplier-negative-alert.task.update', ['token' => $token]), [
'results' => ['', '久柯家具:已处理'],
]);
$this->assertSame(
'completed',
DB::table('supplier_negative_alert_assignments')->value('status'),
);
$this->assertSame(2, DB::table('supplier_negative_alert_results')->count());
}
public function test_latest_period_summary_only_uses_the_newest_accounting_month(): void
{
$summary = app(SupplierNegativeAlertDingTalkWorkflow::class)->latestPeriodSummary([
[
'supplier' => '供应商甲',
'rows' => [
['summary_month' => '2026-08', 'alert_amount' => -100],
['summary_month' => '2026-09', 'alert_amount' => -50],
],
],
[
'supplier' => '供应商乙',
'rows' => [
['summary_month' => '2026-09', 'alert_amount' => -30],
],
],
[
'supplier' => '供应商丙',
'rows' => [
['summary_month' => '2026-08', 'alert_amount' => -200],
],
],
]);
$this->assertSame('2026-09', $summary['month']);
$this->assertSame(-80.0, $summary['negative_amount']);
$this->assertSame(2, $summary['supplier_count']);
$this->assertSame(2, $summary['row_count']);
$this->assertSame(
'2026-10-31 18:00',
app(SupplierNegativeAlertDingTalkWorkflow::class)->processingDeadline('2026-09'),
);
}
public function test_one_click_sends_all_purchasers_in_one_group_message(): void
{
$this->seedBulkRows();
$this->bindTwoPurchaserSupplierManagement();
app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration(
self::WEBHOOK,
self::SECRET,
'财务&采购沟通群',
);
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$this->loginAs()
->post(route('business.supplier-negative-alerts.send-dingtalk'), [
'month' => '2026-09',
])
->assertStatus(302)
->assertSessionHas('success', '已一键发送至财务&采购沟通群:2 位采购,2 家供应商、2 条预警。');
$assignments = DB::table('supplier_negative_alert_assignments')
->orderBy('purchaser_name')
->get();
$this->assertCount(2, $assignments);
$this->assertSame(['sent', 'sent'], $assignments->pluck('status')->all());
$this->assertSame(2, $assignments->sum('supplier_count'));
$this->assertSame(2, $assignments->sum('row_count'));
Http::assertSentCount(1);
Http::assertSent(function ($request): bool {
$text = (string) ($request['markdown']['text'] ?? '');
return ($request['msgtype'] ?? '') === 'markdown'
&& str_contains($text, '采购:**2** 位')
&& str_contains($text, '[采购甲 · 1 家供应商](')
&& str_contains($text, '[采购乙 · 1 家供应商](');
});
$this->get(route('supplier-negative-alert.task', [
'token' => $this->tokenForPurchaser('采购甲'),
]))
->assertOk()
->assertSeeText('三洋木业')
->assertDontSeeText('久柯家具');
}
public function test_daily_command_pushes_only_unhandled_latest_period_suppliers_once_per_day(): void
{
Cache::flush();
$this->seedBulkRows();
$this->bindTwoPurchaserSupplierManagement();
app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration(
self::WEBHOOK,
self::SECRET,
'财务&采购沟通群',
);
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$now = now();
$handledAssignmentId = DB::table('supplier_negative_alert_assignments')->insertGetId([
'company_id' => (int) $this->company->id,
'purchaser_name' => '采购甲',
'group_name' => '财务&采购沟通群',
'filters' => json_encode(['month' => '2026-09']),
'snapshot' => json_encode([[
'supplier' => '三洋木业',
'row_count' => 1,
'months' => ['2026-09'],
'negative_reasons' => ['历史差异待核对'],
'alert_amount' => -100,
'rows' => [[
'summary_month' => '2026-09',
'checker' => '李萌',
'settlement_method' => '订单合同',
'negative_reason' => '历史差异待核对',
'alert_amount' => -100,
]],
]], JSON_UNESCAPED_UNICODE),
'supplier_count' => 1,
'row_count' => 1,
'token_hash' => hash('sha256', 'already-handled-assignment'),
'token_expires_at' => $now->copy()->addDay(),
'status' => 'completed',
'sent_at' => $now,
'completed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('supplier_negative_alert_results')->insert([
'assignment_id' => $handledAssignmentId,
'company_id' => (int) $this->company->id,
'purchaser_name' => '采购甲',
'supplier_name' => '三洋木业',
'period' => '2026-09',
'result_text' => '已处理',
'submitted_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$this->artisan('supplier-negative-alert:push-dingtalk --date=2099-01-01')
->assertSuccessful();
$newAssignment = DB::table('supplier_negative_alert_assignments')
->where('id', '!=', $handledAssignmentId)
->first();
$this->assertNotNull($newAssignment);
$this->assertSame('采购乙', $newAssignment->purchaser_name);
$snapshot = json_decode((string) $newAssignment->snapshot, true);
$this->assertSame(['久柯家具'], array_column($snapshot, 'supplier'));
Http::assertSentCount(1);
Http::assertSent(function ($request): bool {
$text = (string) ($request['markdown']['text'] ?? '');
return str_contains($text, '[采购乙 · 1 家供应商](')
&& ! str_contains($text, '[采购甲 ·');
});
$this->artisan('supplier-negative-alert:push-dingtalk --date=2099-01-01')
->assertSuccessful();
Http::assertSentCount(1);
$this->assertSame(2, DB::table('supplier_negative_alert_assignments')->count());
}
private function seedRows(): void
{
$store = app(SupplierPayableOverrideStore::class);
$dataset = PayablesFinalActions::DATASET_PREFIX . 'supplier:supplier-payables-report';
foreach ([
'sanyang:1' => ['supplier' => '三洋木业', 'supplier_balance' => '-100.00'],
'sanyang:2' => ['supplier' => '三洋木业', 'supplier_balance' => '-50.00'],
'jiuke:1' => ['supplier' => '久柯家具', 'supplier_balance' => '-80.00'],
] as $key => $row) {
$store->save($dataset, $key, $row + [
'summary_month' => '2026-09',
'checker' => '李萌',
'settlement_method' => '订单合同',
'should_pay' => '0.00',
'estimated_price' => '0.00',
'negative_reason' => '历史差异待核对',
], (int) $this->company->id, (int) $this->user->id);
}
}
private function seedBulkRows(): void
{
$store = app(SupplierPayableOverrideStore::class);
$dataset = PayablesFinalActions::DATASET_PREFIX . 'supplier:supplier-payables-report';
foreach ([
'bulk:sanyang' => ['supplier' => '三洋木业', 'supplier_balance' => '-100.00'],
'bulk:jiuke' => ['supplier' => '久柯家具', 'supplier_balance' => '-80.00'],
] as $key => $row) {
$store->save($dataset, $key, $row + [
'summary_month' => '2026-09',
'checker' => '李萌',
'settlement_method' => '订单合同',
'should_pay' => '0.00',
'estimated_price' => '0.00',
'negative_reason' => '历史差异待核对',
], (int) $this->company->id, (int) $this->user->id);
}
}
private function bindSupplierManagement(): void
{
$supplierManagement = \Mockery::mock(SupplierManagementMirrorRepository::class);
$supplierManagement->shouldReceive('suppliersByNames')
->andReturnUsing(static function (array $names): array {
return array_map(
static fn (string $name): object => (object) ['scmname' => $name, 'buyer' => '采购甲'],
$names,
);
});
$this->app->instance(SupplierManagementMirrorRepository::class, $supplierManagement);
}
private function bindTwoPurchaserSupplierManagement(): void
{
$supplierManagement = \Mockery::mock(SupplierManagementMirrorRepository::class);
$supplierManagement->shouldReceive('suppliersByNames')
->andReturnUsing(static function (array $names): array {
return array_map(static fn (string $name): object => (object) [
'scmname' => $name,
'buyer' => $name === '久柯家具' ? '采购乙' : '采购甲',
], $names);
});
$this->app->instance(SupplierManagementMirrorRepository::class, $supplierManagement);
}
/** @param array<string,string> $query */
private function pageUrl(array $query = []): string
{
return route('business.pages.show', array_merge([
'section' => 'negative-alerts',
'page' => 'supplier-negative-alert',
], $query));
}
private function tokenForPurchaser(string $purchaser): string
{
return $this->taskTokenFromLastMessage($purchaser);
}
private function taskTokenFromLastMessage(string $purchaser): string
{
$token = '';
Http::assertSent(function ($request) use ($purchaser, &$token): bool {
$text = (string) ($request['markdown']['text'] ?? '');
if (preg_match('/\[' . preg_quote($purchaser, '/') . '[^\]]*\]\(([^)]+)\)/u', $text, $matches)) {
preg_match('/\/supplier-negative-alert\/task\/([A-Za-z0-9]{64})$/', $matches[1], $tokenMatch);
$token = $tokenMatch[1] ?? '';
}
return $token !== '';
});
return $token;
}
private function actionCardTokenFromLastMessage(): string
{
$url = '';
Http::assertSent(function ($request) use (&$url): bool {
$url = (string) ($request['actionCard']['singleURL'] ?? '');
return $url !== '';
});
preg_match('/\/([A-Za-z0-9]{64})$/', $url, $matches);
$this->assertNotEmpty($matches[1] ?? null);
return $matches[1];
}
}