- 工作台新增「钉钉机器人设置」: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
168 lines
11 KiB
PHP
168 lines
11 KiB
PHP
<?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);
|
||
}
|
||
}
|