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

1 week ago
<?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();
}
}