Merge branch 'feature/candidate_payments' into homo_experimental
This commit is contained in:
@@ -145,6 +145,12 @@ class Purchase extends Model
|
||||
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
||||
public function telepagosPaymentCandidates(): HasMany
|
||||
{
|
||||
return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id');
|
||||
}
|
||||
|
||||
public function getTotalAmount(): float
|
||||
{
|
||||
if ($this->total !== null) {
|
||||
|
||||
@@ -6,10 +6,10 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'compra_id',
|
||||
'matched_purchase_ids',
|
||||
'cuit_buyer',
|
||||
'cvu_buyer',
|
||||
'amount',
|
||||
@@ -30,7 +30,6 @@ class TelepagosPayment extends Model
|
||||
{
|
||||
return [
|
||||
'compra_id' => 'integer',
|
||||
'matched_purchase_ids' => 'array',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -42,4 +41,10 @@ class TelepagosPayment extends Model
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
||||
public function candidates(): HasMany
|
||||
{
|
||||
return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id');
|
||||
}
|
||||
}
|
||||
|
||||
50
app/Domains/Purchase/Models/TelepagosPaymentCandidate.php
Normal file
50
app/Domains/Purchase/Models/TelepagosPaymentCandidate.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'telepagos_payment_id',
|
||||
'compra_id',
|
||||
'dni_matches',
|
||||
'dni_distance',
|
||||
'payment_dni',
|
||||
'purchase_dni',
|
||||
'amount_matches',
|
||||
'payment_amount',
|
||||
'purchase_amount',
|
||||
'amount_difference',
|
||||
'match_reason',
|
||||
'confidence',
|
||||
])]
|
||||
class TelepagosPaymentCandidate extends Model
|
||||
{
|
||||
protected $table = 'telepagos_payment_candidates';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'telepagos_payment_id' => 'integer',
|
||||
'compra_id' => 'integer',
|
||||
'dni_matches' => 'boolean',
|
||||
'dni_distance' => 'integer',
|
||||
'amount_matches' => 'boolean',
|
||||
'payment_amount' => 'decimal:2',
|
||||
'purchase_amount' => 'decimal:2',
|
||||
'amount_difference' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function payment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TelepagosPayment::class, 'telepagos_payment_id');
|
||||
}
|
||||
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Resources;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -25,6 +26,7 @@ class PurchaseResource extends JsonResource
|
||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||
? (int) $this->resource->getAttribute('tickets_count')
|
||||
: null;
|
||||
$paymentVerification = $this->resolvePaymentVerification();
|
||||
|
||||
$subtotal = $items->isNotEmpty()
|
||||
? $items->reduce(
|
||||
@@ -64,6 +66,7 @@ class PurchaseResource extends JsonResource
|
||||
'items' => PurchaseItemResource::collection($items),
|
||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
'total' => $this->formatMoney($total),
|
||||
];
|
||||
@@ -83,4 +86,75 @@ class PurchaseResource extends JsonResource
|
||||
{
|
||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private function resolvePaymentVerification(): ?array
|
||||
{
|
||||
if (
|
||||
$this->status !== Purchase::STATUS_IN_REVIEW
|
||||
|| $this->payment_method !== 'transfer'
|
||||
|| ! $this->resource->relationLoaded('telepagosPaymentCandidates')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = $this->resource
|
||||
->getRelation('telepagosPaymentCandidates')
|
||||
->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right))
|
||||
->values();
|
||||
/** @var TelepagosPaymentCandidate|null $primary */
|
||||
$primary = $candidates->first();
|
||||
|
||||
return [
|
||||
'status' => $primary === null ? 'pending' : 'candidate',
|
||||
'candidate_count' => $candidates->count(),
|
||||
'primary' => $primary === null ? null : [
|
||||
'reason' => $primary->match_reason,
|
||||
'dni_distance' => $primary->dni_distance,
|
||||
'payment_amount' => $this->formatMoney($primary->payment_amount),
|
||||
'purchase_amount' => $this->formatMoney($primary->purchase_amount),
|
||||
'amount_difference' => $this->formatMoney($primary->amount_difference),
|
||||
'confidence' => $primary->confidence,
|
||||
'detected_at' => $primary->payment?->created_at?->toIso8601String(),
|
||||
],
|
||||
'reasons' => $candidates
|
||||
->pluck('match_reason')
|
||||
->unique()
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function comparePaymentCandidates(
|
||||
TelepagosPaymentCandidate $left,
|
||||
TelepagosPaymentCandidate $right,
|
||||
): int {
|
||||
$reasonComparison = $this->paymentCandidateRank($left->match_reason)
|
||||
<=> $this->paymentCandidateRank($right->match_reason);
|
||||
|
||||
if ($reasonComparison !== 0) {
|
||||
return $reasonComparison;
|
||||
}
|
||||
|
||||
$differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference;
|
||||
|
||||
if ($differenceComparison !== 0) {
|
||||
return $differenceComparison;
|
||||
}
|
||||
|
||||
$leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0;
|
||||
$rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0;
|
||||
|
||||
return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id);
|
||||
}
|
||||
|
||||
private function paymentCandidateRank(string $reason): int
|
||||
{
|
||||
return match ($reason) {
|
||||
'ambiguous_exact_match' => 0,
|
||||
'exact_dni_near_amount' => 1,
|
||||
'exact_amount_near_dni' => 2,
|
||||
default => 3,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ class PurchaseResponseLoader
|
||||
{
|
||||
public function load(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']);
|
||||
$relations = ['tenant', 'items.imageAttachment', 'stockReservation'];
|
||||
|
||||
if (
|
||||
$purchase->status === Purchase::STATUS_IN_REVIEW
|
||||
&& $purchase->payment_method === 'transfer'
|
||||
) {
|
||||
$relations[] = 'telepagosPaymentCandidates.payment';
|
||||
}
|
||||
|
||||
return $purchase->load($relations);
|
||||
}
|
||||
}
|
||||
|
||||
82
app/Domains/Purchase/Services/DniDistanceService.php
Normal file
82
app/Domains/Purchase/Services/DniDistanceService.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Services;
|
||||
|
||||
/**
|
||||
* Measures likely DNI typing errors using the optimal-string-alignment
|
||||
* variant of the Damerau-Levenshtein distance.
|
||||
*
|
||||
* The returned value is the minimum number of single-character edits needed
|
||||
* to transform one DNI into the other. Supported edits are insertion,
|
||||
* deletion, substitution and transposition of two adjacent digits.
|
||||
*/
|
||||
class DniDistanceService
|
||||
{
|
||||
/**
|
||||
* Calculate the edit distance between two normalized DNI strings.
|
||||
*
|
||||
* Each matrix cell [row][column] stores the minimum edits required to
|
||||
* transform the first $row digits of $left into the first $column digits
|
||||
* of $right. The bottom-right cell therefore contains the final distance.
|
||||
*/
|
||||
public function distance(string $left, string $right): int
|
||||
{
|
||||
$left = $this->normalize($left);
|
||||
$right = $this->normalize($right);
|
||||
$leftLength = strlen($left);
|
||||
$rightLength = strlen($right);
|
||||
$matrix = [];
|
||||
|
||||
// Transforming a prefix into an empty string requires deleting every digit.
|
||||
for ($row = 0; $row <= $leftLength; $row++) {
|
||||
$matrix[$row] = [$row];
|
||||
}
|
||||
|
||||
// Transforming an empty string into a prefix requires inserting every digit.
|
||||
for ($column = 0; $column <= $rightLength; $column++) {
|
||||
$matrix[0][$column] = $column;
|
||||
}
|
||||
|
||||
for ($row = 1; $row <= $leftLength; $row++) {
|
||||
for ($column = 1; $column <= $rightLength; $column++) {
|
||||
$substitutionCost = $left[$row - 1] === $right[$column - 1] ? 0 : 1;
|
||||
$deletionDistance = $matrix[$row - 1][$column] + 1;
|
||||
$insertionDistance = $matrix[$row][$column - 1] + 1;
|
||||
$substitutionDistance = $matrix[$row - 1][$column - 1] + $substitutionCost;
|
||||
|
||||
// Keep the cheapest way to align the two prefixes at this position.
|
||||
$matrix[$row][$column] = min(
|
||||
$deletionDistance,
|
||||
$insertionDistance,
|
||||
$substitutionDistance,
|
||||
);
|
||||
|
||||
// Count two adjacent inverted digits as one edit instead of two substitutions.
|
||||
if (
|
||||
$row > 1
|
||||
&& $column > 1
|
||||
&& $left[$row - 1] === $right[$column - 2]
|
||||
&& $left[$row - 2] === $right[$column - 1]
|
||||
) {
|
||||
$matrix[$row][$column] = min(
|
||||
$matrix[$row][$column],
|
||||
$matrix[$row - 2][$column - 2] + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matrix[$leftLength][$rightLength];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only digits and left-pad seven-digit DNIs so comparisons preserve
|
||||
* the leading zero that is present when the DNI is extracted from a CUIT.
|
||||
*/
|
||||
public function normalize(string $dni): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $dni) ?? '';
|
||||
|
||||
return str_pad($digits, 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user