企业户调整

master
lion 4 days ago
parent b3c0d3b252
commit 5336c8c478

@ -9,19 +9,25 @@ class AutoSchoolmate extends Command
{
protected $signature = 'auto_schoolmate';
protected $description = '已审核学员自动进入校友库(含历史遗漏及开课后审核通过人员)';
protected $description = '自动核验校友资格,补齐符合条件的学员并取消失去资格的校友';
public function handle(SchoolmateEnrollmentService $service)
{
$updated = 0;
$service->pendingUsers($service->automaticCourses())
->chunkById(200, function ($users) use ($service, &$updated) {
foreach ($users as $user) {
$updated += $service->promote($user->id, $user->source_start_date);
}
}, 'users.id', 'id');
$courses = $service->automaticCourses();
$added = 0;
$removed = 0;
$reconcile = function ($users) use ($service, $courses, &$added, &$removed) {
foreach ($users as $user) {
$change = $service->reconcile($user->id, $courses);
$added += (int) ($change === 1);
$removed += (int) ($change === -1);
}
};
$this->info("自动入库完成,共补齐 {$updated} 位学员。");
$service->pendingUsers($courses)->chunkById(200, $reconcile, 'users.id', 'id');
$service->ineligibleSchoolmates($courses)->chunkById(200, $reconcile, 'users.id', 'id');
$this->info("校友资格核验完成,新增 {$added} 人,取消 {$removed} 人。");
return 0;
}
}

@ -20,7 +20,8 @@ class QccEnterpriseAccountController extends CommonController
$keyword = trim((string) $request->input('keyword', ''));
$status = $request->input('status');
$pageSize = min(max((int) $request->input('page_size', 20), 1), 100);
$query = Company::query()->select('id', 'company_name', 'credit_code', 'updated_at')->with('qccAccount');
$query = Company::query()->select('id', 'company_name', 'credit_code', 'updated_at')->with('qccAccount')
->whereHas('users', fn ($user) => $user->where('is_schoolmate', 1));
if ($keyword !== '') {
$query->where(function ($subQuery) use ($keyword) {
$subQuery->where('company_name', 'like', '%' . $keyword . '%')
@ -31,7 +32,7 @@ class QccEnterpriseAccountController extends CommonController
$query->with(['users' => $this->studentRelation()]);
if ($status === 'none') {
$query->doesntHave('qccAccount');
} elseif (in_array($status, [CompanyQccAccount::STATUS_SELECTED, CompanyQccAccount::STATUS_OCCUPIED, CompanyQccAccount::STATUS_UNKNOWN], true)) {
} elseif (in_array($status, [CompanyQccAccount::STATUS_SELECTED, CompanyQccAccount::STATUS_OCCUPIED, CompanyQccAccount::STATUS_UNKNOWN, CompanyQccAccount::STATUS_NON_OCCUPYING], true)) {
$query->whereHas('qccAccount', fn($relation) => $relation->where('status', $status));
}
return $this->studentResponse($query->orderByDesc('id')->paginate($pageSize));
@ -48,7 +49,7 @@ class QccEnterpriseAccountController extends CommonController
if ($keyword !== '') $company->where(fn ($q) => $q->where('company_name', 'like', '%'.$keyword.'%')->orWhere('credit_code', 'like', '%'.$keyword.'%'));
$this->applyStudentFilters($company, $request);
});
if (in_array($status, [CompanyQccAccount::STATUS_SELECTED, CompanyQccAccount::STATUS_OCCUPIED, CompanyQccAccount::STATUS_UNKNOWN], true)) {
if (in_array($status, [CompanyQccAccount::STATUS_SELECTED, CompanyQccAccount::STATUS_OCCUPIED, CompanyQccAccount::STATUS_UNKNOWN, CompanyQccAccount::STATUS_NON_OCCUPYING], true)) {
$query->where('status', $status);
}
return $this->studentResponse($query->orderByDesc('id')->paginate($pageSize));
@ -112,6 +113,16 @@ class QccEnterpriseAccountController extends CommonController
}
public function select(Request $request)
{
return $this->enroll($request, CompanyQccAccount::STATUS_SELECTED);
}
public function selectNonOccupying(Request $request)
{
return $this->enroll($request, CompanyQccAccount::STATUS_NON_OCCUPYING);
}
private function enroll(Request $request, string $status)
{
$validator = Validator::make($request->all(), ['company_ids' => 'required|array|min:1', 'company_ids.*' => 'integer|distinct']);
if ($validator->fails()) {
@ -127,7 +138,7 @@ class QccEnterpriseAccountController extends CommonController
}
try {
DB::transaction(function () use ($companyIds) {
DB::transaction(function () use ($companyIds, $status) {
$existingCompanyIds = CompanyQccAccount::query()->whereIn('company_id', $companyIds)->lockForUpdate()->pluck('company_id');
if ($existingCompanyIds->isNotEmpty()) {
throw new RuntimeException('存在已纳入企业户标注的企业');
@ -135,7 +146,7 @@ class QccEnterpriseAccountController extends CommonController
foreach ($companyIds as $companyId) {
CompanyQccAccount::query()->create([
'company_id' => $companyId,
'status' => CompanyQccAccount::STATUS_SELECTED,
'status' => $status,
'selected_by' => $this->getUserId(),
'selected_at' => now(),
]);
@ -144,8 +155,9 @@ class QccEnterpriseAccountController extends CommonController
} catch (RuntimeException|QueryException $exception) {
return $this->fail([ResponseCode::ERROR_BUSINESS, $exception->getMessage()]);
}
$this->log('纳入企查查企业户候选池', implode(',', $companyIds));
return $this->success('已纳入候选池');
$label = $status === CompanyQccAccount::STATUS_NON_OCCUPYING ? '不占额企业' : '候选池';
$this->log('纳入企查查企业户'.$label, implode(',', $companyIds));
return $this->success('已纳入'.$label);
}
public function cancel(Request $request)

@ -7,6 +7,7 @@ class CompanyQccAccount extends CommonModel
public const STATUS_SELECTED = 'selected';
public const STATUS_OCCUPIED = 'occupied';
public const STATUS_UNKNOWN = 'unknown';
public const STATUS_NON_OCCUPYING = 'non_occupying';
protected $casts = [
'selected_at' => 'datetime:Y-m-d H:i:s',

@ -15,7 +15,17 @@ class SchoolmateEnrollmentService
*/
public function pendingUsers($courses)
{
$sources = CourseSign::query()
return User::query()
->joinSub($this->eligibleSources($courses), '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');
}
private function eligibleSources($courses)
{
return CourseSign::query()
->join('courses', 'courses.id', '=', 'course_signs.course_id')
->whereNull('courses.deleted_at')
->whereIn('courses.id', (clone $courses)->select('courses.id'))
@ -23,13 +33,42 @@ class SchoolmateEnrollmentService
->select('course_signs.user_id')
->selectRaw('MIN(courses.start_date) as source_start_date')
->groupBy('course_signs.user_id');
}
public function ineligibleSchoolmates($courses)
{
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');
->where('users.is_schoolmate', 1)
->whereNotIn('users.id', $this->eligibleSources($courses)
->select('course_signs.user_id')->whereNotNull('course_signs.user_id'));
}
/**
* 锁定用户后重新核验,避免按批次查询时取得的旧资格更新用户。
* 返回 1 表示入库,-1 表示取消,0 表示无需变更。
*/
public function reconcile(int $userId, $courses): int
{
return DB::transaction(function () use ($userId, $courses) {
$user = User::whereKey($userId)->lockForUpdate()->first();
if (!$user) {
return 0;
}
$source = $this->eligibleSources($courses)
->where('course_signs.user_id', $userId)->first();
if ($source && $user->is_schoolmate != 1) {
return User::batchUpdateToSchoolmate([$userId], $source->source_start_date);
}
if (!$source && $user->is_schoolmate == 1) {
$user->is_schoolmate = 0;
$user->schoolmate_time = null;
return $user->save() ? -1 : 0;
}
return 0;
});
}
public function automaticCourses()

@ -127,3 +127,7 @@ npx vue-cli-service test:unit tests/unit/components/CompanyNews.spec.js --runInB
```
后端测试使用 SQLite 内存数据库、模拟元禾响应,不访问业务数据库、不调用收费接口。上线人工验收:创建包含候选和已占额企业的小规模批次,查看进度、历史与失败明细;重复采集验证新闻不重复,编辑/留存后再采集验证人工数据保持。
## 企业户不占额状态
企业户候选列表固定要求企业至少关联一名 `users.is_schoolmate=1` 的未删除学员,其余学员和课程筛选继续叠加。`company_qcc_accounts.status=non_occupying` 表示不占额企业,区别于没有企业户记录的未纳入。后台可将未纳入且有信用代码的企业批量标记为不占额;不会覆盖已有企业户记录,也不调用外部接口。不占额企业不进入手动或自动新闻采集名单。状态字段为字符串,本次无需新增数据库迁移。

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -76,6 +76,7 @@ Route::group(["namespace" => "Admin", "prefix" => "admin"], function () {
// 企查查企业户标注
Route::get('qcc-enterprise-accounts/candidates', [\App\Http\Controllers\Admin\QccEnterpriseAccountController::class, 'candidates']);
Route::get('qcc-enterprise-accounts/index', [\App\Http\Controllers\Admin\QccEnterpriseAccountController::class, 'index']);
Route::post('qcc-enterprise-accounts/select-non-occupying', [\App\Http\Controllers\Admin\QccEnterpriseAccountController::class, 'selectNonOccupying']);
Route::post('qcc-enterprise-accounts/select', [\App\Http\Controllers\Admin\QccEnterpriseAccountController::class, 'select']);
Route::post('qcc-enterprise-accounts/cancel', [\App\Http\Controllers\Admin\QccEnterpriseAccountController::class, 'cancel']);
Route::post('qcc-enterprise-accounts/unknown-confirm', [\App\Http\Controllers\Admin\QccEnterpriseAccountController::class, 'unknownConfirm']);

@ -85,14 +85,14 @@ class CompanyNewsWorkflowTest extends TestCase
}
public function test_failure_does_not_stop_other_companies_or_change_account_states(): void
{
$this->company(1); $this->company(2); $this->company(3, 'occupied'); $this->company(4, 'unknown'); $this->company(5, 'unregistered');
$this->company(1); $this->company(2); $this->company(3, 'occupied'); $this->company(4, 'unknown'); $this->company(5, 'unregistered'); $this->company(6, 'non_occupying');
$this->provider([new RuntimeException('请求超时'), $this->response(null), $this->response([])]);
$service = new NewsCollection(); $batch = $service->start(1);
$this->assertEquals(3, $batch->total); $service->runOne(); $service->runOne(); $service->runOne();
$summary = $service->summary($batch->id);
$this->assertEquals(2, $summary->success); $this->assertEquals(1, $summary->failed);
$this->assertEquals(100, $summary->progress); $this->assertEquals('completed', $summary->status);
$this->assertEquals(['selected', 'selected', 'occupied', 'unknown', 'unregistered'], DB::table('company_qcc_accounts')->orderBy('company_id')->pluck('status')->all());
$this->assertEquals(['selected', 'selected', 'occupied', 'unknown', 'unregistered', 'non_occupying'], DB::table('company_qcc_accounts')->orderBy('company_id')->pluck('status')->all());
}
public function test_invalid_news_is_recorded_while_valid_news_is_imported(): void
{

@ -14,7 +14,7 @@ class QccEnterpriseStudentFilterTest extends TestCase
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]);
DB::purge('sqlite');
Schema::create('companies', function (Blueprint $t) { $t->id(); $t->string('company_name'); $t->string('credit_code'); $t->timestamps(); $t->softDeletes(); });
Schema::create('company_qcc_accounts', function (Blueprint $t) { $t->id(); $t->integer('company_id'); $t->string('status'); });
Schema::create('company_qcc_accounts', function (Blueprint $t) { $t->id(); $t->integer('company_id')->unique(); $t->string('status'); $t->integer('selected_by')->nullable(); $t->dateTime('selected_at')->nullable(); $t->timestamps(); });
Schema::create('users', function (Blueprint $t) { $t->id(); $t->integer('company_id'); $t->string('name'); $t->string('username'); $t->string('no')->nullable(); $t->string('company_position')->nullable(); $t->integer('is_schoolmate'); $t->softDeletes(); });
Schema::create('courses', function (Blueprint $t) { $t->id(); $t->string('name'); $t->integer('type'); $t->date('start_date')->nullable(); $t->softDeletes(); });
Schema::create('course_signs', function (Blueprint $t) { $t->id(); $t->integer('course_id'); $t->integer('user_id'); $t->integer('status'); $t->softDeletes(); });
@ -37,15 +37,19 @@ class QccEnterpriseStudentFilterTest extends TestCase
{
return (new QccEnterpriseAccountController())->$method(Request::create('/', 'GET', $params))->getData(true);
}
public function test_unfiltered_companies_remain_and_only_approved_students_are_shown(): void
public function test_candidates_require_an_alumnus_and_do_not_duplicate_companies(): void
{
$result = $this->query([]);
$this->assertEquals(3, $result['total']);
$this->assertEquals(1, $result['total']);
$company = collect($result['data'])->firstWhere('id', 1);
$this->assertCount(2, $company['users']);
$this->assertCount(1, $company['users'][0]['course_signs']);
$this->assertEquals('管理课程', $company['users'][0]['course_signs'][0]['course']['name']);
$this->assertEquals([], collect($result['data'])->firstWhere('id', 2)['users']);
$this->assertEquals([1], array_column($result['data'], 'id'));
DB::table('users')->where('id', 2)->update(['is_schoolmate' => 1]);
$this->assertEquals(1, $this->query([])['total']);
DB::table('users')->where('company_id', 1)->update(['deleted_at' => now()]);
$this->assertEquals(0, $this->query([])['total']);
}
public function test_combined_filters_match_same_student_and_same_approved_course(): void
{
@ -58,4 +62,48 @@ class QccEnterpriseStudentFilterTest extends TestCase
}
$this->assertCount(2, $this->query(['keyword' => 'credit1'], 'index')['data'][0]['company']['users']);
}
private function actionController(): QccEnterpriseAccountController
{
return new class extends QccEnterpriseAccountController {
public function getUserId() { return 7; }
protected function log(string $name, string $remark = ''): void {}
};
}
public function test_bulk_non_occupying_enrollment_and_status_filter(): void
{
DB::table('company_qcc_accounts')->where('company_id', 1)->delete();
$controller = $this->actionController();
$controller->selectNonOccupying(Request::create('/', 'POST', ['company_ids' => [1]]));
$row = DB::table('company_qcc_accounts')->where('company_id', 1)->first();
$this->assertEquals('non_occupying', $row->status);
$this->assertEquals(7, $row->selected_by);
$this->assertNotNull($row->selected_at);
$this->assertFalse(\App\Models\CompanyQccAccount::where('company_id', 1)->first()->isCallable());
foreach (['candidates', 'index'] as $method) {
$this->assertEquals(1, $this->query(['status' => 'non_occupying'], $method)['total']);
$this->assertEquals(0, $this->query(['status' => 'selected'], $method)['total']);
}
$this->assertEquals(0, $this->query(['status' => 'none'])['total']);
}
public function test_bulk_non_occupying_rejects_existing_states_without_partial_changes(): void
{
DB::table('company_qcc_accounts')->where('company_id', 1)->delete();
foreach (['occupied', 'selected', 'unknown', 'non_occupying'] as $status) {
DB::table('company_qcc_accounts')->where('company_id', 2)->update(['status' => $status]);
$result = $this->actionController()->selectNonOccupying(Request::create('/', 'POST', ['company_ids' => [1, 2]]))->getData(true);
$this->assertArrayHasKey('errcode', $result);
$this->assertFalse(DB::table('company_qcc_accounts')->where('company_id', 1)->exists());
$this->assertEquals($status, DB::table('company_qcc_accounts')->where('company_id', 2)->value('status'));
}
$result = $this->actionController()->selectNonOccupying(Request::create('/', 'POST', ['company_ids' => [1, 999]]))->getData(true);
$this->assertArrayHasKey('errcode', $result);
$this->assertFalse(DB::table('company_qcc_accounts')->where('company_id', 1)->exists());
}
public function test_candidate_enrollment_still_creates_selected_status(): void
{
DB::table('company_qcc_accounts')->where('company_id', 1)->delete();
$this->actionController()->select(Request::create('/', 'POST', ['company_ids' => [1]]));
$this->assertEquals('selected', DB::table('company_qcc_accounts')->where('company_id', 1)->value('status'));
}
}

@ -96,13 +96,22 @@ class SchoolmateEnrollmentCommandTest extends TestCase
$this->sign($id, min($id, 4), $id === 5 ? 0 : 1);
}
User::withoutEvents(function () {
$this->artisan('auto_schoolmate')->expectsOutputToContain('共补齐 2 位')->assertExitCode(0);
$this->artisan('auto_schoolmate')->expectsOutputToContain('新增 2 人,取消 0 人')->assertExitCode(0);
// 开课后才通过审核的历史课程报名,下次任务仍能处理。
$this->sign(5, 1);
$this->artisan('auto_schoolmate')->expectsOutputToContain('共补齐 1 位')->assertExitCode(0);
$this->artisan('auto_schoolmate')->expectsOutputToContain('新增 1 人,取消 0 人')->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'));
// 同一门课延期后,仅有该课作为依据的校友被取消。
$this->sign(5, 2);
DB::table('courses')->where('id', 2)->update(['start_date' => '2026-11-01']);
$this->artisan('auto_schoolmate')->expectsOutputToContain('新增 0 人,取消 1 人')->assertExitCode(0);
$this->assertEquals(0, DB::table('users')->where('id', 2)->value('is_schoolmate'));
$this->assertNull(DB::table('users')->where('id', 2)->value('schoolmate_time'));
$this->assertEquals(1, DB::table('users')->where('id', 5)->value('is_schoolmate'));
$this->artisan('auto_schoolmate')->expectsOutputToContain('新增 0 人,取消 0 人')->assertExitCode(0);
}
public function test_invalid_missing_deleted_or_undated_courses_abort_without_partial_updates(): void

Loading…
Cancel
Save