You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

164 lines
9.7 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Services\Course;
use App\Models\Course;
use App\Models\CourseSign;
use App\Models\CourseAppointmentTotal;
use App\Models\ThirdAppointmentLog;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/** All enrollment writes take the course lock, including imports and reactivation. */
class CompanyRegistration
{
public const ACTIVE = [0, 1, 3];
public static function canonical($name): string
{
return mb_strtolower(preg_replace('/[\s\x{3000}]+/u', '', str_replace(['(', ')'], ['(', ')'], trim((string) $name))));
}
public static function companyName(array $data, array $fields = []): string
{
foreach ($data as $answer) {
if (!is_array($answer) || !empty($answer['hidden'])) continue;
$definition = $answer;
foreach ($fields as $field) if (($field['field'] ?? null) === ($answer['field'] ?? null)) { $definition = $field; break; }
if (($definition['belong_user_table'] ?? '') === 'company_name' || ($answer['field'] ?? '') === 'company_name') {
return is_scalar($answer['value'] ?? null) ? trim((string) $answer['value']) : '';
}
}
return '';
}
/** Submitted answers take priority; profile fallback is frozen once enrolled. */
public static function resolvedCompanyName(CourseSign $sign, array $fields = []): string
{
$name = self::companyName($sign->data ?: [], $fields);
if ($name !== '') return $name;
if ($sign->exists && trim((string) $sign->company_name_snapshot) !== '') return trim($sign->company_name_snapshot);
return trim((string) (User::find($sign->user_id)->company_name ?? ''));
}
public static function identity(CourseSign $sign): string
{
if ($sign->company_key) return $sign->company_key;
$name = self::resolvedCompanyName($sign, $sign->form_snapshot['fields'] ?? []);
return $name !== '' ? 'name:'.hash('sha256', self::canonical($name)) : '';
}
public static function occupants(CourseSign $sign)
{
$identity = self::identity($sign);
return CourseSign::where('course_id', $sign->course_id)->whereIn('status', self::ACTIVE)
->when($sign->exists, fn ($query) => $query->where('id', '!=', $sign->id))
->orderBy('id')->lockForUpdate()->get()->filter(fn ($other) => self::identity($other) === $identity)->values();
}
/** Called only under the course lock by CourseSign::save(). */
public static function guard(CourseSign $sign, Course $course): void
{
if (!(int) $course->one_person_per_company) return;
if ($sign->exists) {
$current = CourseSign::whereKey($sign->id)->lockForUpdate()->firstOrFail();
if ((int) $current->status !== (int) $sign->getRawOriginal('status')) throw new InvalidArgumentException('报名状态已变化,请刷新后重试');
if ($current->replaced_by_sign_id && ($sign->isDirty('data') || $sign->isDirty('fee_file_ids'))) throw new InvalidArgumentException('该报名已被更换,原报名信息只读,请重新报名');
if ($current->replaced_by_sign_id && in_array((int) $sign->status, self::ACTIVE, true)) throw new InvalidArgumentException('该报名已被更换,请重新提交报名,不可恢复原报名');
}
if (!in_array((int) ($sign->status ?? 0), self::ACTIVE, true)) return;
if (!$sign->exists || $sign->isDirty('data') || !$sign->company_key) {
$name = self::resolvedCompanyName($sign, $sign->form_snapshot['fields'] ?? CourseFormSchema::definitions($course->id));
if ($name === '') throw new InvalidArgumentException('请先完善个人信息中的公司名称,或在报名表中填写公司名称');
$sign->company_name_snapshot = $name;
$sign->company_key = 'name:'.hash('sha256', self::canonical($name));
}
if (CourseSign::where('course_id', $course->id)->where('user_id', $sign->user_id)->where('status', 6)->lockForUpdate()->first()) throw new InvalidArgumentException('您当前无法报名该课程,请联系管理员');
$self = CourseSign::where('course_id', $course->id)->where('user_id', $sign->user_id)->whereIn('status', self::ACTIVE)
->when($sign->exists, fn ($query) => $query->where('id', '!=', $sign->id))->lockForUpdate()->first();
if ($self) throw new InvalidArgumentException('请勿重复报名');
if (self::occupants($sign)->isNotEmpty()) throw new InvalidArgumentException('该公司已有有效报名,请先处理原报名后再操作');
}
private static function replacementError(CourseSign $old): void
{
if ((int) $old->fee_status === 1 || !empty($old->fee_file_ids)) throw new InvalidArgumentException('贵公司已有报名已缴费或提交缴费凭证,请联系管理员办理更换');
if (ThirdAppointmentLog::where('course_sign_id', $old->id)->where(function ($query) {
$query->where('plate_status', 1)->orWhere(function ($query) {
$query->where('finally', 1)->whereNotNull('door_name')->where(function ($query) { $query->whereNull('end_time')->orWhere('end_time', '>', now()); });
});
})->exists()) throw new InvalidArgumentException('贵公司已有报名已开通门禁或停车权限,请联系管理员办理更换');
}
private static function fingerprint(CourseSign $old, CourseSign $new): string
{
return hash('sha256', json_encode([$old->id, $old->getRawOriginal(), $new->course_id, $new->user_id, self::identity($new), $new->data], JSON_UNESCAPED_UNICODE));
}
/** Read-only on conflict until the exact current occupant has been confirmed. */
public static function submit(array $attributes, ?string $token = null): array
{
return DB::transaction(function () use ($attributes, $token) {
$course = Course::whereKey($attributes['course_id'])->lockForUpdate()->firstOrFail();
$new = new CourseSign($attributes);
$new->status = 0;
if (!(int) $course->one_person_per_company) {
if (CourseSign::where('course_id', $course->id)->where('user_id', $new->user_id)->exists()) throw new InvalidArgumentException('请勿重复报名');
$new->save(); return ['sign' => $new];
}
$fields = $attributes['form_snapshot']['fields'] ?? CourseFormSchema::definitions($course->id);
$name = self::resolvedCompanyName($new, $fields);
if ($name === '') throw new InvalidArgumentException('请先完善个人信息中的公司名称,或在报名表中填写公司名称');
$new->company_name_snapshot = $name;
$new->company_key = 'name:'.hash('sha256', self::canonical($name));
if (CourseSign::where('course_id', $course->id)->where('user_id', $new->user_id)->whereIn('status', [0, 1, 3, 6])->lockForUpdate()->first()) throw new InvalidArgumentException('您已报名该课程,请查看原报名记录');
$occupants = self::occupants($new);
if ($occupants->count() > 1) throw new InvalidArgumentException('贵公司存在多条历史有效报名,请联系管理员处理后再报名');
$old = $occupants->first();
if (!$old) { $new->save(); return ['sign' => $new]; }
self::replacementError($old);
$fingerprint = self::fingerprint($old, $new);
$confirmed = false;
if ($token) try {
$payload = json_decode(Crypt::decryptString($token), true);
$confirmed = ($payload['expires'] ?? 0) >= time() && hash_equals($fingerprint, (string) ($payload['fingerprint'] ?? ''));
} catch (\Throwable $e) { /* Return a fresh confirmation; never replace a changed occupant. */ }
$oldName = $old->participantName();
if (!$confirmed) return ['replacement_required' => true, 'replacement_name' => $oldName,
'replacement_token' => Crypt::encryptString(json_encode(['fingerprint' => $fingerprint, 'expires' => time() + 600])),
'message' => '贵公司'.$oldName.'已提交报名,请确认是否更换为您报名参训。确认后,'.$oldName.'的报名将调整为“主动放弃”,您的报名需重新审核。'];
$newName = $new->participantName();
$old->status = 5;
$old->giveup_reason = '因同公司更换参训人员,已由'.$newName.'替换报名。';
$old->replaced_at = now();
$old->save();
$new->replaces_sign_id = $old->id;
$new->save();
$old->replaced_by_sign_id = $new->id;
$old->save();
CourseAppointmentTotal::reduce($old->user_id, $old->id);
return ['sign' => $new];
});
}
/** Serialize queued grants with replacement so a delayed job cannot restore old access. */
public static function grantAccess(int $signId, callable $grant): void
{
DB::transaction(function () use ($signId, $grant) {
$sign = CourseSign::find($signId);
if (!$sign) return;
Course::whereKey($sign->course_id)->lockForUpdate()->first();
$sign = CourseSign::find($signId);
if ($sign && (int) $sign->status === 1) $grant($sign);
});
}
public static function duplicates(int $courseId): array
{
return CourseSign::where('course_id', $courseId)->whereIn('status', self::ACTIVE)->get()->groupBy(fn ($sign) => self::identity($sign))
->filter(fn ($signs, $key) => $key !== '' && $signs->count() > 1)->map(fn ($signs) => ['company' => self::resolvedCompanyName($signs->first(), $signs->first()->form_snapshot['fields'] ?? []), 'ids' => $signs->pluck('id')->all()])->values()->all();
}
}