File: /var/www/api_matriculas/app/Services/ApiInboundService.php
<?php
namespace App\Services;
use App\Models\Contract;
use App\Models\ContractDetail;
use App\Models\Course;
use App\Models\Parents;
use App\Models\Period;
use App\Models\Student;
use App\Models\SyncLog;
use App\Repositories\ContractRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Exception;
class ApiInboundService
{
protected $contractRepository;
// Play Group / Jardin course codes (same treatment: no signature required)
const PG_COURSE_CODE = 'PG';
const PG_COURSE_ID = 14;
const JI_COURSE_CODE = 'JI';
const JI_COURSE_ID = 1;
public function __construct(ContractRepository $contractRepository)
{
$this->contractRepository = $contractRepository;
}
/**
* Trigger automático de contrato al cargar un alumno via API
* - Si curso = PG → contrato separado con enrollment_type = 'playgroup', firma = 'not_required'
* - Si curso != PG → contrato separado con enrollment_type = 'permanent', firma = 'pending'
* PG y no-PG van a contratos distintos del mismo apoderado
*/
public function triggerContractForStudent($studentId, $parentId, $courseId, $periodId, $forceEnrollmentType = null)
{
DB::beginTransaction();
try {
$student = Student::find($studentId);
if (!$student) {
throw new Exception("Estudiante ID {$studentId} no encontrado");
}
$parent = Parents::find($parentId);
if (!$parent) {
throw new Exception("Apoderado ID {$parentId} no encontrado");
}
// Determine enrollment type based on course or forced type
if ($forceEnrollmentType) {
$isPlayGroup = false;
$enrollmentType = $forceEnrollmentType;
} else {
$isPlayGroup = $this->isPlayGroupCourse($courseId);
$enrollmentType = $isPlayGroup ? 'playgroup' : 'permanent';
}
// Check if an active (in_course) contract already exists for this enrollment type + period
$inCourseStatusId = $this->contractRepository->getStatus('in_course', 'contract');
$existingContract = Contract::where('financial_parent_id', $parentId)
->where('period_id', $periodId)
->where('enrollment_type', $enrollmentType)
->where('status_contract_id', $inCourseStatusId)
->first();
if ($existingContract) {
// Solo agregar alumno si el contrato no tiene pagos ni firma
$pendingPaymentId = $this->contractRepository->getStatus('pending', 'payment');
$pendingSignatureId = $this->contractRepository->getStatus('pending', 'signature');
$notRequiredSignatureId = $this->contractRepository->getStatus('not_required', 'signature');
$canAddStudent = $existingContract->status_payment_id == $pendingPaymentId
&& in_array($existingContract->status_signature_id, [$pendingSignatureId, $notRequiredSignatureId]);
if (!$canAddStudent) {
DB::commit();
Log::info("Contrato {$existingContract->code_contract} ya tiene pagos o firma. No se puede agregar alumno.", [
'contract_id' => $existingContract->id,
'student_id' => $studentId,
'status_payment_id' => $existingContract->status_payment_id,
'status_signature_id' => $existingContract->status_signature_id,
]);
return [
'success' => false,
'action' => 'skipped_has_payments_or_signature',
'contract_code' => $existingContract->code_contract,
'contract_id' => $existingContract->id,
'enrollment_type' => $enrollmentType,
'message' => 'El contrato ya tiene pagos o firma. No se puede agregar alumno.',
];
}
$this->addStudentToContract($existingContract, $student, $periodId);
DB::commit();
$this->logSync('api_inbound', 'inbound', 'contract', $existingContract->id, 'success', [
'action' => 'student_added_to_existing_contract',
'student_id' => $studentId,
'contract_code' => $existingContract->code_contract,
]);
return [
'success' => true,
'action' => 'student_added',
'contract_code' => $existingContract->code_contract,
'contract_id' => $existingContract->id,
'enrollment_type' => $existingContract->enrollment_type,
];
}
// Si ya existe un contrato finalizado para este tipo/período → no crear otro
$finishedStatusId = $this->contractRepository->getStatus('finished', 'contract');
$finishedContract = Contract::where('financial_parent_id', $parentId)
->where('period_id', $periodId)
->where('enrollment_type', $enrollmentType)
->where('status_contract_id', $finishedStatusId)
->first();
if ($finishedContract) {
DB::commit();
Log::info("Contrato finalizado ya existe para apoderado {$parentId}, período {$periodId}, tipo {$enrollmentType}. No se crea nuevo.", [
'contract_id' => $finishedContract->id,
'contract_code' => $finishedContract->code_contract,
'student_id' => $studentId,
]);
return [
'success' => true,
'action' => 'skipped_finished',
'contract_code' => $finishedContract->code_contract,
'contract_id' => $finishedContract->id,
'enrollment_type' => $enrollmentType,
'message' => 'Contrato finalizado existente. No se creó uno nuevo.',
];
}
// No active/finished contract — create a new one (cancelled ones are ignored)
// Generate unique contract code (sin sufijo -PG, correlativo estándar)
$periodYear = Period::where('id', $periodId)->value('period_year') ?? date('Y');
$contractCode = $this->generateContractCode($periodYear, $parent->id, $periodId);
// Create new contract
$isPassport = ($parent->document_type ?? 'RUT') === 'PASSPORT';
$signatureStatus = $isPlayGroup ? 'not_required' : ($isPassport ? 'pending_manual' : 'pending');
$contract = $this->contractRepository->findOrcreate($contractCode, $parent, $periodId);
// Update enrollment type and signature status
$contract->enrollment_type = $enrollmentType;
$contract->status_contract_id = $this->contractRepository->getStatus('in_course', 'contract');
$contract->status_payment_id = $this->contractRepository->getStatus('pending', 'payment');
$contract->status_signature_id = $this->contractRepository->getStatus($signatureStatus, 'signature');
$contract->observation = $isPlayGroup
? 'Contrato Play Group generado automáticamente desde API.'
: 'Contrato Admisión Permanente generado automáticamente desde API.';
$contract->save();
// Add student to contract
$this->addStudentToContract($contract, $student, $periodId);
DB::commit();
$this->logSync('api_inbound', 'inbound', 'contract', $contract->id, 'success', [
'action' => 'contract_created',
'student_id' => $studentId,
'contract_code' => $contractCode,
'enrollment_type' => $enrollmentType,
]);
return [
'success' => true,
'action' => 'contract_created',
'contract_code' => $contractCode,
'contract_id' => $contract->id,
'enrollment_type' => $enrollmentType,
];
} catch (Exception $e) {
DB::rollBack();
Log::error('Error en triggerContractForStudent: ' . $e->getMessage(), [
'student_id' => $studentId,
'parent_id' => $parentId,
]);
$this->logSync('api_inbound', 'inbound', 'contract', null, 'error', [
'student_id' => $studentId,
'parent_id' => $parentId,
], null, $e->getMessage());
throw $e;
}
}
/**
* Agregar un estudiante al detalle de un contrato existente
* Calcula y crea las deudas automáticamente
*/
private function addStudentToContract(Contract $contract, Student $student, $periodId)
{
// Check if student already has details in this contract
$existingDetails = ContractDetail::where('contract_id', $contract->id)
->where('student_id', $student->id)
->exists();
if ($existingDetails) {
Log::info("Estudiante {$student->id} ya tiene detalles en contrato {$contract->id}");
return;
}
// Calculate and create debts for this student
$debtService = app(DebtCalculationService::class);
$debts = $debtService->calculateStudentDebts($student->id, $periodId, $contract->id);
Log::info("Estudiante {$student->id} asociado a contrato {$contract->code_contract} con " . count($debts) . " deudas", [
'contract_id' => $contract->id,
'course_id' => $student->course_id,
'period_id' => $periodId,
'debts_count' => count($debts),
]);
// Recalcular deudas de hermanos (el nuevo alumno puede alterar el sibling ordering)
if ($student->family) {
try {
$debtService->recalculateFamilySiblingDebts($student->financial_parent_id, $periodId);
} catch (Exception $e) {
Log::warning("Error recalculando hermanos tras agregar estudiante {$student->id}: " . $e->getMessage());
}
}
}
/**
* Generate a unique contract code with global sequential correlative.
* Format: CTR-YYYY-NNNN where NNNN is the next available number for that year.
*/
public function generateContractCode($periodYear, $parentId = null, $periodId = null)
{
$prefix = sprintf('CTR-%s-', $periodYear);
// Find the highest existing correlative for this year (only exact format CTR-YYYY-NNNN)
$lastCode = Contract::where('code_contract', 'like', $prefix . '%')
->whereRaw("code_contract REGEXP ?", ['^CTR-' . $periodYear . '-[0-9]+$'])
->orderByRaw("CAST(SUBSTRING(code_contract, ?) AS UNSIGNED) DESC", [strlen($prefix) + 1])
->value('code_contract');
$nextNumber = 1;
if ($lastCode) {
$numericPart = substr($lastCode, strlen($prefix));
$parsed = intval($numericPart);
if ($parsed > 0) {
$nextNumber = $parsed + 1;
}
}
return sprintf('CTR-%s-%04d', $periodYear, $nextNumber);
}
/**
* Determine if a course is Play Group
*/
private function isPlayGroupCourse($courseId): bool
{
if (in_array($courseId, [self::PG_COURSE_ID, self::JI_COURSE_ID])) {
return true;
}
$course = Course::find($courseId);
return $course && in_array($course->code, [self::PG_COURSE_CODE, self::JI_COURSE_CODE]);
}
/**
* Log sync operation
*/
private function logSync($syncType, $direction, $entityType, $entityId, $status, $requestPayload = null, $responsePayload = null, $errorMessage = null)
{
SyncLog::create([
'sync_type' => $syncType,
'direction' => $direction,
'entity_type' => $entityType,
'entity_id' => $entityId,
'status' => $status,
'request_payload' => $requestPayload,
'response_payload' => $responsePayload,
'error_message' => $errorMessage,
]);
}
}