parent
9024174feb
commit
d378d6899c
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Course;
|
||||
use App\Models\CourseSign;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SchoolmateEnrollmentService
|
||||
{
|
||||
/**
|
||||
* 仅选择未删除、尚未成为校友且有有效审核通过报名的学员。
|
||||
* 多门课程取最早开课日期;指定课程补齐不受自动入库开关限制。
|
||||
*/
|
||||
public function pendingUsers($courses)
|
||||
{
|
||||
$sources = CourseSign::query()
|
||||
->join('courses', 'courses.id', '=', 'course_signs.course_id')
|
||||
->whereNull('courses.deleted_at')
|
||||
->whereIn('courses.id', (clone $courses)->select('courses.id'))
|
||||
->where('course_signs.status', 1)
|
||||
->select('course_signs.user_id')
|
||||
->selectRaw('MIN(courses.start_date) as source_start_date')
|
||||
->groupBy('course_signs.user_id');
|
||||
|
||||
return User::query()
|
||||
->joinSub($sources, 'schoolmate_sources', function ($join) {
|
||||
$join->on('users.id', '=', 'schoolmate_sources.user_id');
|
||||
})
|
||||
->whereRaw('COALESCE(users.is_schoolmate, 0) != 1')
|
||||
->select('users.*', 'schoolmate_sources.source_start_date');
|
||||
}
|
||||
|
||||
public function automaticCourses()
|
||||
{
|
||||
// 保留开课前一天入库的原行为,同时补查历史及开课后审核通过的学员。
|
||||
return Course::where('auto_schoolmate', 1)
|
||||
->whereNotNull('start_date')
|
||||
->whereDate('start_date', '<=', now()->addDay()->toDateString());
|
||||
}
|
||||
|
||||
public function promote(int $userId, string $startDate): int
|
||||
{
|
||||
return DB::transaction(function () use ($userId, $startDate) {
|
||||
// 手工补齐和自动任务同时运行时,不覆盖已有校友的入库时间。
|
||||
$user = User::whereKey($userId)->lockForUpdate()->first();
|
||||
if (!$user || $user->is_schoolmate == 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return User::batchUpdateToSchoolmate([$userId], $startDate);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SchoolmateEnrollmentCommandTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]);
|
||||
DB::purge('sqlite');
|
||||
Carbon::setTestNow('2026-10-15 10:00:00');
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('测试学员');
|
||||
$table->string('mobile')->nullable();
|
||||
$table->integer('is_schoolmate')->nullable();
|
||||
$table->dateTime('schoolmate_time')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
Schema::create('courses', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('测试课程');
|
||||
$table->date('start_date')->nullable();
|
||||
$table->integer('auto_schoolmate')->default(0);
|
||||
$table->softDeletes();
|
||||
});
|
||||
Schema::create('course_signs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->integer('course_id');
|
||||
$table->integer('user_id');
|
||||
$table->integer('status')->default(1);
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function course(int $id, ?string $date, int $auto = 0): void
|
||||
{
|
||||
DB::table('courses')->insert(['id' => $id, 'start_date' => $date, 'auto_schoolmate' => $auto]);
|
||||
}
|
||||
|
||||
private function sign(int $userId, int $courseId, int $status = 1, ?string $deleted = null): void
|
||||
{
|
||||
DB::table('course_signs')->insert(['user_id' => $userId, 'course_id' => $courseId, 'status' => $status, 'deleted_at' => $deleted]);
|
||||
}
|
||||
|
||||
public function test_preview_does_not_write_and_execute_is_idempotent_and_preserves_existing_schoolmates(): void
|
||||
{
|
||||
$this->course(1, '2025-06-01');
|
||||
$this->course(2, '2024-03-01');
|
||||
DB::table('users')->insert([
|
||||
['id' => 1, 'is_schoolmate' => null, 'schoolmate_time' => null, 'deleted_at' => null],
|
||||
['id' => 2, 'is_schoolmate' => 1, 'schoolmate_time' => '2020-01-01 00:00:00', 'deleted_at' => null],
|
||||
['id' => 3, 'is_schoolmate' => 0, 'schoolmate_time' => null, 'deleted_at' => '2025-01-01 00:00:00'],
|
||||
['id' => 4, 'is_schoolmate' => 0, 'schoolmate_time' => null, 'deleted_at' => null],
|
||||
['id' => 5, 'is_schoolmate' => 0, 'schoolmate_time' => null, 'deleted_at' => null],
|
||||
]);
|
||||
foreach ([1, 2, 3] as $id) $this->sign($id, 1);
|
||||
$this->sign(1, 2);
|
||||
$this->sign(1, 2); // 同人重复报名只补一次
|
||||
$this->sign(4, 1, 0);
|
||||
$this->sign(5, 1, 1, '2025-01-01 00:00:00');
|
||||
$this->artisan('schoolmate:backfill', ['course_ids' => '1,2'])->expectsOutputToContain('发现遗漏 1 人')->assertExitCode(0);
|
||||
$this->assertNull(DB::table('users')->where('id', 1)->value('is_schoolmate'));
|
||||
User::withoutEvents(function () {
|
||||
$this->artisan('schoolmate:backfill', ['course_ids' => '1,2', '--execute' => true])->expectsOutputToContain('实际补齐 1 人')->assertExitCode(0);
|
||||
});
|
||||
$this->assertSame('2024-03-01 00:00:00', DB::table('users')->where('id', 1)->value('schoolmate_time'));
|
||||
$this->assertSame('2020-01-01 00:00:00', DB::table('users')->where('id', 2)->value('schoolmate_time'));
|
||||
foreach ([3, 4, 5] as $id) $this->assertEquals(0, DB::table('users')->where('id', $id)->value('is_schoolmate'));
|
||||
$this->artisan('schoolmate:backfill', ['course_ids' => '1,2', '--execute' => true])->expectsOutputToContain('实际补齐 0 人')->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_automatic_enrollment_catches_history_and_late_approval_but_respects_course_switch_and_date(): void
|
||||
{
|
||||
$this->course(1, '2024-01-01', 1);
|
||||
$this->course(2, '2026-10-16', 1);
|
||||
$this->course(3, '2026-10-17', 1);
|
||||
$this->course(4, '2024-01-01', 0);
|
||||
foreach ([1, 2, 3, 4, 5] as $id) {
|
||||
DB::table('users')->insert(['id' => $id, 'is_schoolmate' => 0]);
|
||||
$this->sign($id, min($id, 4), $id === 5 ? 0 : 1);
|
||||
}
|
||||
User::withoutEvents(function () {
|
||||
$this->artisan('auto_schoolmate')->expectsOutputToContain('共补齐 2 位')->assertExitCode(0);
|
||||
// 开课后才通过审核的历史课程报名,下次任务仍能处理。
|
||||
$this->sign(5, 1);
|
||||
$this->artisan('auto_schoolmate')->expectsOutputToContain('共补齐 1 位')->assertExitCode(0);
|
||||
});
|
||||
foreach ([1, 2, 5] as $id) $this->assertEquals(1, DB::table('users')->where('id', $id)->value('is_schoolmate'));
|
||||
foreach ([3, 4] as $id) $this->assertEquals(0, DB::table('users')->where('id', $id)->value('is_schoolmate'));
|
||||
}
|
||||
|
||||
public function test_invalid_missing_deleted_or_undated_courses_abort_without_partial_updates(): void
|
||||
{
|
||||
$this->course(1, '2024-01-01');
|
||||
$this->course(2, null);
|
||||
$this->course(3, '2024-01-01');
|
||||
DB::table('courses')->where('id', 3)->update(['deleted_at' => '2025-01-01']);
|
||||
DB::table('users')->insert(['id' => 1, 'is_schoolmate' => 0]);
|
||||
$this->sign(1, 1);
|
||||
foreach (['1,0', '1,999', '1,2', '1,3'] as $ids) {
|
||||
$this->artisan('schoolmate:backfill', ['course_ids' => $ids, '--execute' => true])->assertExitCode(1);
|
||||
}
|
||||
$this->assertEquals(0, DB::table('users')->where('id', 1)->value('is_schoolmate'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue