CaiWuXiTong/tests/Feature/Business/PlantingReimbursementWorkbenchTest.php
limeng f78149e630 feat(种菜报销): 接入钉钉群机器人(取消通知 @ 发起人、每天 10:25 推送汇总),看板职级为空改为取消 (#1150)
- 工作台新增「钉钉机器人设置」:Webhook、加签密钥加密存公司设置,页面不回显;支持自定义关键词、汇总单独发群、@ 报销人花名
- 核对后点「发取消通知到钉钉群」:按发起人一人一条,按钉钉花名册(花名 → userid)@ 发起人;同一批已发过的不重复发,可勾选重发
- 新增 planting-reimbursement:daily-digest,每天 10:25(宿主机 cron)生成当天批次,把待核对条数/金额、可报销合计、取消/待复查数、自检结果和工作台链接推到群里;数据停更或看板未上传会提醒
- 种菜监控看板提示「职级为空」的单改为取消工单,群通知原因写「职级为空」(2026-11-13 报销人确认)
- 没上传花名册时,收款人在职改按钉钉花名册在职真名判断;两边都取不到时不做该项校验并提示,避免误判
涉及:只读聚工单镜像与钉钉花名册副本;钉钉群机器人发消息;不涉及迁移,不写聚工单。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-11-13 11:15:04 +08:00

168 lines
11 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\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Modules\EcommerceFinance\Services\PlantingReimbursementDirectory;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository;
use Modules\EcommerceFinance\Services\PlantingReimbursementReferences;
use Tests\Feature\FeatureTestCase;
class PlantingReimbursementWorkbenchTest extends FeatureTestCase
{
private function row(string $orderNo, string $amount, array $patch = []): array
{
return array_replace([
'work_order_no' => 'GD' . $orderNo, 'online_order_no' => $orderNo, 'status' => '待处理', 'paid_at' => '2026-11-11 10:00:00',
'started_at' => '2026-11-11 12:00:00', 'completed_at' => '', 'initiator' => '白菜', 'executor' => '李斯', 'completer' => '',
'platform' => '天猫', 'shop_name' => '天猫-欧意朗旗舰店', 'internal_order_no' => '1', 'task_remark' => '', 'seller_remark' => 'CCDD',
'actual_paid_amount' => $amount, 'task_payment' => $amount, 'task_freight' => '0.00', 'task_commission' => '0.00',
'jingxi_price_diff' => '0.00', 'task_deduction' => '0.00', 'task_empty_package_fee' => '0.00', 'reimbursement_amount' => $amount,
'payee_name' => '姚佳炜', 'payee_account' => '6228760405006454377', 'payee_bank' => '农业银行', 'payee_mobile' => '13800000000',
'erp_order_label' => '工单同步', 'erp_order_status' => '已发货', 'platform_order_status' => '', 'product_code' => '',
'product_name' => '', 'product_quantity' => '', 'platform_product_id' => '',
], $patch);
}
private function mockRepository(array $rows): void
{
$repository = \Mockery::mock(PlantingReimbursementRepository::class);
$repository->shouldReceive('syncStatus')->andReturn(['code' => 'ready', 'last_succeeded_at' => '2026-11-12 12:57:00']);
$repository->shouldReceive('pendingRows')->with('2026-11-05 00:00:00', '2026-11-11 23:59:59', ['WAIT_CONFIRM'])->andReturn($rows);
$repository->shouldReceive('reimbursedOrders')->andReturn(['900' => '2026-11-03']);
$this->app->instance(PlantingReimbursementRepository::class, $repository);
Storage::fake('local');
$directory = \Mockery::mock(PlantingReimbursementDirectory::class);
$directory->shouldReceive('userIds')->andReturnUsing(static fn (array $names): array => array_intersect_key(['白菜' => 'U-BAICAI', '星璃' => 'U-XINGLI', '胖虎' => 'U-PANGHU'], array_flip($names)));
$directory->shouldReceive('activeNames')->andReturn(['姚佳炜' => true, '王敏佳' => true]);
$this->app->instance(PlantingReimbursementDirectory::class, $directory);
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
private function upload(string $kind, string $name, string $content)
{
return $this->loginAs()->post(route('business.planting-data.planting-reimbursement-workbench.upload', [
'company_id' => $this->company->id, 'kind' => $kind,
]), ['date' => '2026-11-12', 'file' => UploadedFile::fake()->createWithContent($name, $content)]);
}
private function url(array $query = []): string
{
return route('business.pages.show', ['company_id' => $this->company->id, 'section' => 'planting-data', 'page' => 'planting-reimbursement-workbench'])
. ($query ? '?' . http_build_query($query) : '');
}
public function test_workbench_audits_pending_rows_and_exports(): void
{
$this->mockRepository([
$this->row('101', '60.00'),
$this->row('102', '40.50', ['shop_name' => '京喜-星光闪闪京喜自营工厂店', 'initiator' => '星璃', 'payee_name' => '王敏佳']),
$this->row('900', '10.00', ['initiator' => '星璃']),
$this->row('103', '20.00', ['payee_name' => '离职的人']),
$this->row('104', '30.00'),
]);
$this->upload('commission', '报销佣金人员.csv', "平台,人员,佣金,人员,平台\n天猫,王也,10元,白菜,天猫\n")->assertRedirect();
$this->upload('roster', '花名册.csv', "序号,花名,姓名\n1,白,姚佳炜\n2,星,王敏佳\n")->assertRedirect();
$this->upload('shop', '店铺信息.csv', "开店时间,ERP系统店铺名称,店铺所属利润中心\n2021-01-01,天猫-欧意朗旗舰店,运营中心A\n2021-01-01,京喜-星光闪闪京喜自营工厂店,运营中心A\n")->assertRedirect();
// 看板是 GBK 编码导出;104 没匹配到,应待复查。
$monitor = mb_convert_encoding("订单号,风险,提示,风险类型,ID负责人,日限额,支付时间\n101,正常,,,白菜,,2026/11/11 10:00\n102,正常,,,星璃,,2026/11/11 10:00\n900,正常,,,星璃,,2026/11/11 10:00\n103,正常,,,白菜,,2026/11/11 10:00\n", 'GB18030', 'UTF-8');
$this->upload('monitor', '种菜报销明细.csv', $monitor)->assertRedirect()->assertSessionHas('success');
$this->assertSame(['白菜' => true], app(PlantingReimbursementReferences::class)->nameSet($this->company->id, 'commission'));
$this->assertSame(['姚佳炜' => true, '王敏佳' => true], app(PlantingReimbursementReferences::class)->nameSet($this->company->id, 'roster'));
$this->loginAs()->get($this->url(['date' => '2026-11-12']))
->assertOk()
->assertSee('种菜报销工作台', false)
->assertSee('2026-11-05 00:00:00')
->assertSee('100.50')
->assertSee('11.11种菜报销')
->assertSee('欧意朗')
->assertSee('重复报销')
->assertSee('不在在职花名册')
->assertSee('@星璃 以上订单本次不予报销', false)
->assertSee('待复查')
->assertSee('应为')
->assertSee('5 条', false)
->assertSee('钉钉明细合计 = 可报销合计');
$response = $this->loginAs()->get(route('business.planting-data.planting-reimbursement-workbench.export', [
'company_id' => $this->company->id, 'type' => 'reconciliation', 'date' => '2026-11-12', 'verified' => '1',
]));
$response->assertOk();
$this->assertStringContainsString('11.11.xlsx', (string) $response->headers->get('content-disposition'));
$this->assertStringStartsWith('PK', $response->streamedContent());
foreach (['details', 'import-template'] as $type) {
$this->loginAs()->get(route('business.planting-data.planting-reimbursement-workbench.export', [
'company_id' => $this->company->id, 'type' => $type, 'date' => '2026-11-12', 'verified' => '1',
]))->assertOk();
}
// 没勾「已核对」不给导出。
$this->loginAs()->get(route('business.planting-data.planting-reimbursement-workbench.export', [
'company_id' => $this->company->id, 'type' => 'details', 'date' => '2026-11-12',
]))->assertRedirect()->assertSessionHas('error');
$this->withExceptionHandling()->loginAs()->get($this->url(['date' => '2026-13-40']))->assertStatus(422);
$this->withExceptionHandling()->loginAs()->get($this->url(['date' => '2026-11-12', 'paid_from' => '2026-11-12']))->assertStatus(422);
$this->withExceptionHandling()->loginAs()->get($this->url(['date' => '2026-11-12', 'scope' => 'x']))->assertStatus(422);
}
public function test_bad_upload_reports_error(): void
{
$this->mockRepository([]);
$this->upload('monitor', 'x.csv', "a,b\n1,2\n")->assertRedirect()->assertSessionHas('error');
$this->upload('roster', 'x.txt', "姓名\n张三\n")->assertRedirect()->assertSessionHas('error');
}
public function test_robot_settings_notices_and_daily_digest(): void
{
$this->mockRepository([
$this->row('101', '60.00'),
$this->row('900', '10.00', ['initiator' => '星璃']),
$this->row('103', '20.00', ['payee_name' => '离职的人']),
]);
Http::fake(['oapi.dingtalk.com/*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$robotUrl = route('business.planting-data.planting-reimbursement-workbench.robot', ['company_id' => $this->company->id]);
$notifyUrl = route('business.planting-data.planting-reimbursement-workbench.notify', ['company_id' => $this->company->id]);
$this->withExceptionHandling()->loginAs()->post($robotUrl, ['webhook' => 'https://example.com/robot/send?access_token=x'])->assertSessionHasErrors('webhook');
$this->loginAs()->post($robotUrl, [
'webhook' => 'https://oapi.dingtalk.com/robot/send?access_token=SECRET-TOKEN-1234',
'secret' => 'SECabc', 'keyword' => '种菜报销', 'reporters' => '胖虎', 'date' => '2026-11-12',
])->assertRedirect()->assertSessionHas('success');
// 页面不回显 Webhook 和密钥;没上传花名册时按钉钉花名册在职名单判断。
$page = $this->loginAs()->get($this->url(['date' => '2026-11-12']))->assertOk()->assertSee('钉钉机器人设置(已配置)')->assertSee('钉钉花名册');
$page->assertDontSee('SECRET-TOKEN-1234')->assertDontSee('SECabc');
// 未勾选已核对不发。
$this->loginAs()->post($notifyUrl, ['date' => '2026-11-12'])->assertSessionHas('error');
Http::assertNothingSent();
$this->loginAs()->post($notifyUrl, ['date' => '2026-11-12', 'verified' => '1'])->assertSessionHas('success');
Http::assertSentCount(2);
Http::assertSent(static fn ($request): bool => str_contains($request->url(), 'sign=')
&& $request['at']['atUserIds'] === ['U-XINGLI'] && str_contains($request['markdown']['text'], '900'));
Http::assertSent(static fn ($request): bool => $request['at']['atUserIds'] === ['U-BAICAI'] && str_contains($request['markdown']['text'], '103'));
// 同一批再点不重复发。
$this->loginAs()->post($notifyUrl, ['date' => '2026-11-12', 'verified' => '1'])->assertSessionHas('success', '群通知:发送 0 人,跳过已发 2 人。');
Http::assertSentCount(2);
$this->artisan('planting-reimbursement:daily-digest', ['--date' => '2026-11-12'])->assertExitCode(0);
Http::assertSentCount(3);
Http::assertSent(static fn ($request): bool => str_contains($request['markdown']['text'], '可报销:1 条')
&& str_contains($request['markdown']['text'], '3 条 / 90.00 元') && $request['at']['atUserIds'] === ['U-PANGHU']);
// 当天只推一次。
$this->artisan('planting-reimbursement:daily-digest', ['--date' => '2026-11-12'])->assertExitCode(0);
Http::assertSentCount(3);
}
}