每日自动推送未处理供应商,增加处理截止时间和截图上传。 Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
502 lines
21 KiB
PHP
502 lines
21 KiB
PHP
<?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];
|
||
}
|
||
}
|