ComplaintTransitionManager
in package
uses
SourceLockTransactionTrait
Single entry point for changing a complaint status; the admin StatusForm and the expiry cron always call it.
Unlike the order transition it has no gating matrix, reservations, payments or invoicing, only an
optional stock return (ComplaintStatus::$returnStock), an optional e-mail
(ComplaintStatus::$sendEmail) and the status write.
The stock return is blocking: a missing 4/complaint.returnWarehouseId or a failed
DocumentService::receive() rejects the transition, so no complaint enters a status that claims
"returned to stock" without a real return. The admin fixes the setting and repeats apply()
(idempotent through ComplaintStockManager; repeating the same status is a no-op).
Locking: per complaint GET_LOCK('cms_eshop_complaint_{id}'), the same name as in
ComplaintStockManager (reentrant in one MySQL session, so nested calls are safe).
The e-mail render runs before the final transaction (I/O outside the lock); the queue insert goes
through the transactional outbox inside the final transaction together with the status write.
Transition policy: once a complaint has a stock return (stock_document_id) it may only move to
a status with returnStock=1 (currently approved and partially approved). A status that denies the
goods receipt (rejected, expired, back to processing) would split the customer outcome from the
stock, so it fails with stockAlreadyReturned.
Order of steps: render e-mail, stock return (its own Store transaction, cannot be nested), then one transaction with status and queue. When the last step fails after a return created by this transition, the return is compensated by a cancellation (ComplaintStockManager::compensateReturn()).
Table of Contents
Constants
- COMPLAINT_INCLUDES : array<string|int, mixed> = ['items', 'status.translations', 'order.languag...
- Includes for the fresh load under the lock, everything `receiveReturn()` and `prepareStatusEmail()` need.
- COMPLAINTS_TABLE : string = 'cms_mod_eshop_complaints'
- EXPIRE_DEFAULT_BATCH : int = 50
- EXPIRE_DUE_PREDICATE : string = 'c.`expiration_date` IS NOT NULL AND c.`expirat...
- The single definition of "complaint is due for expiry" (alias `c` = complaint, `s` = its current status).
- EXPIRE_QUEUE : string = 'eshop.complaint.expire'
- EXPIRED_STATUS_KEY : string = 'EXPIRED'
- FK_STATUS : string = 'cms_mod_eshop_complaint_statuses_complaint_sta...
- LOCK_MAX_ATTEMPTS : int = 3
- LOCK_SCALE : int = 3
- LOCK_TIMEOUT : int = 5
- STATUSES_TABLE : string = 'cms_mod_eshop_complaint_statuses'
Properties
- $complaintManager : ComplaintManager
- $complaintStatusManager : ComplaintStatusManager
- $cronFailureManager : CronFailureManager
- $em : EntityManagerInterface
- $emailGenerator : ComplaintEmailGenerator
- $emailQueueManager : EmailQueueManager
- $stockManager : ComplaintStockManager
Methods
- __construct() : mixed
- apply() : Result
- Moves the complaint to the given status.
-
expireOverdue()
: array{processed: int, expired: int, skipped: int, errors: list
} - Moves complaints past `expiration_date` that are in a non-final status (`final=0`) and not yet in the target expired status to the expired status (like {@see \App\UI\Api\Eshop\Models\Managers\Traits\OrderCronTrait::expireUnpaid()}).
- isTransitionAllowed() : bool
- Tells whether a complaint with or without a stock return may move to the target status.
- acquireSourceLock() : bool
- Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).
- connection() : Connection
- lockLogChannel() : string
- releaseSourceLock() : void
- Releases the advisory lock.
- runInSourceTransaction() : T
- Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).
- expireDueComplaintIds() : array<int, int>
- Returns ids of complaints past `expiration_date` whose current status is not final yet.
- isExpireDue() : bool
- ladd() : numeric-string
- lcmp() : int
- ldec() : numeric-string
- ldiv() : numeric-string
- Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
- lInt() : int
- lmul() : numeric-string
- ln() : numeric-string
- lockName() : string
- lStr() : string
- lStrN() : string|null
- lsub() : numeric-string
- optInt() : int|null
- persistedStockDocumentId() : int|null
- transition() : Result
Constants
COMPLAINT_INCLUDES
Includes for the fresh load under the lock, everything `receiveReturn()` and `prepareStatusEmail()` need.
public
array<string|int, mixed>
COMPLAINT_INCLUDES
= ['items', 'status.translations', 'order.language', 'preferredSolution.translations']
COMPLAINTS_TABLE
private
string
COMPLAINTS_TABLE
= 'cms_mod_eshop_complaints'
EXPIRE_DEFAULT_BATCH
private
int
EXPIRE_DEFAULT_BATCH
= 50
EXPIRE_DUE_PREDICATE
The single definition of "complaint is due for expiry" (alias `c` = complaint, `s` = its current status).
private
string
EXPIRE_DUE_PREDICATE
= 'c.`expiration_date` IS NOT NULL AND c.`expiration_date` < NOW() AND s.`final` = 0'
Shared by the cron selection (expireDueComplaintIds()) and the check under the lock in
transition() (opt cronExpire).
EXPIRE_QUEUE
private
string
EXPIRE_QUEUE
= 'eshop.complaint.expire'
EXPIRED_STATUS_KEY
private
string
EXPIRED_STATUS_KEY
= 'EXPIRED'
FK_STATUS
private
string
FK_STATUS
= 'cms_mod_eshop_complaint_statuses_complaint_status_id'
LOCK_MAX_ATTEMPTS
private
int
LOCK_MAX_ATTEMPTS
= 3
LOCK_SCALE
private
int
LOCK_SCALE
= 3
LOCK_TIMEOUT
private
int
LOCK_TIMEOUT
= 5
STATUSES_TABLE
private
string
STATUSES_TABLE
= 'cms_mod_eshop_complaint_statuses'
Properties
$complaintManager read-only
private
ComplaintManager
$complaintManager
$complaintStatusManager read-only
private
ComplaintStatusManager
$complaintStatusManager
$cronFailureManager read-only
private
CronFailureManager
$cronFailureManager
$em read-only
private
EntityManagerInterface
$em
$emailGenerator read-only
private
ComplaintEmailGenerator
$emailGenerator
$emailQueueManager read-only
private
EmailQueueManager
$emailQueueManager
$stockManager read-only
private
ComplaintStockManager
$stockManager
Methods
__construct()
public
__construct(EntityManagerInterface $em, ComplaintManager $complaintManager, ComplaintStatusManager $complaintStatusManager, ComplaintStockManager $stockManager, ComplaintEmailGenerator $emailGenerator, EmailQueueManager $emailQueueManager, CronFailureManager $cronFailureManager) : mixed
Parameters
- $em : EntityManagerInterface
- $complaintManager : ComplaintManager
- $complaintStatusManager : ComplaintStatusManager
- $stockManager : ComplaintStockManager
- $emailGenerator : ComplaintEmailGenerator
- $emailQueueManager : EmailQueueManager
- $cronFailureManager : CronFailureManager
apply()
Moves the complaint to the given status.
public
apply(int $complaintId, int $newStatusId[, array<string, mixed> $opts = [] ]) : Result
$opts: sendEmail (bool, default true) disables the e-mail; userId (int) is who made the
transition (goes to the Store return document); cronExpire (bool, default false) marks the
expiry cron path, which re-checks EXPIRE_DUE_PREDICATE after the fresh load under the
lock and before the return/status write. If an admin closed the complaint or extended the
deadline meanwhile, the result is a successful no-op (noop=true), not an error. Manual
transitions do not pass it.
Success data: ['noop'=>bool,'stockDocumentId'=>?int]. Failure errorCode:
nestedTransaction, complaintLocked, complaintNotFound, statusNotFound,
stockAlreadyReturned, stockReturnFailed, transitionFailed.
Parameters
- $complaintId : int
- $newStatusId : int
- $opts : array<string, mixed> = []
Return values
ResultexpireOverdue()
Moves complaints past `expiration_date` that are in a non-final status (`final=0`) and not yet in the target expired status to the expired status (like {@see \App\UI\Api\Eshop\Models\Managers\Traits\OrderCronTrait::expireUnpaid()}).
public
expireOverdue([int|null $limit = null ]) : array{processed: int, expired: int, skipped: int, errors: list}
It has no cancellation logic of its own: a read-only raw SQL select of the due ids, then
apply() on each, which handles the fresh load under the lock, the e-mail and (if the expired
status had returnStock, which the seed does not) the stock return. Idempotent: after success the
complaint has a final=1 status and drops out of the next selection.
A non-final complaint that already has a stock return (status with returnStock=1 and final=0)
is refused by the transition policy (stockAlreadyReturned) and counted as skipped with a message.
Such a complaint would fail every run, so it is backed off (CronFailureManager) and skipped
by the selection; otherwise 50 of them at the head of the overdue list would block expiry of all
others. After the attempts are exhausted a notification is sent and it is retried once per 24 hours.
Parameters
- $limit : int|null = null
Return values
array{processed: int, expired: int, skipped: int, errors: listisTransitionAllowed()
Tells whether a complaint with or without a stock return may move to the target status.
public
static isTransitionAllowed(int|null $stockDocumentId, bool $targetReturnsStock) : bool
After a return only a status with returnStock=1 is allowed; it confirms the return
(receiveReturn() is a no-op for it).
Parameters
- $stockDocumentId : int|null
- $targetReturnsStock : bool
Return values
boolacquireSourceLock()
Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).
protected
acquireSourceLock(string $name, int $timeout) : bool
Parameters
- $name : string
- $timeout : int
Return values
boolconnection()
protected
connection() : Connection
Return values
ConnectionlockLogChannel()
protected
lockLogChannel() : string
Return values
stringreleaseSourceLock()
Releases the advisory lock.
protected
releaseSourceLock(string $name) : void
IMPORTANT: it is called from a finally, so a failure here (a gone-away connection after a
fatal query) is swallowed and logged; rethrowing would mask the original exception being
unwound. MySQL drops session locks when the connection dies anyway, so nothing leaks past
the process.
Parameters
- $name : string
runInSourceTransaction()
Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).
protected
runInSourceTransaction(callable(Connection): T $fn) : T
The advisory lock (held by the caller across this call) is not released by a rollback, so every attempt runs under the same serialization.
Parameters
- $fn : callable(Connection): T
Tags
Return values
TexpireDueComplaintIds()
Returns ids of complaints past `expiration_date` whose current status is not final yet.
private
expireDueComplaintIds(int $limit[, array<int, int> $parkedIds = [] ]) : array<int, int>
Read-only raw SQL on the module's own tables; the fresh load is done by apply(). Parked
(backed-off) complaints come in as a list of ids and are excluded.
Parameters
- $limit : int
- $parkedIds : array<int, int> = []
Return values
array<int, int>isExpireDue()
private
isExpireDue(int $complaintId) : bool
Parameters
- $complaintId : int
Return values
boolladd()
private
ladd(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringlcmp()
private
lcmp(string $a, string $b) : int
Parameters
- $a : string
- $b : string
Return values
intldec()
private
ldec(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringldiv()
Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
private
ldiv(string $a, string $b) : numeric-string
IMPORTANT: truncation would systematically drop up to 0.001 from every unit on large quantities, so the invariant sum(unitPrice x quantity) ~ sum(rowBase) would drift in one direction (an issue document undervalued against the order).
Parameters
- $a : string
- $b : string
Return values
numeric-stringlInt()
private
lInt(mixed $value) : int
Parameters
- $value : mixed
Return values
intlmul()
private
lmul(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringln()
private
ln(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringlockName()
private
lockName(int $complaintId) : string
Parameters
- $complaintId : int
Return values
stringlStr()
private
lStr(mixed $value) : string
Parameters
- $value : mixed
Return values
stringlStrN()
private
lStrN(mixed $value) : string|null
Parameters
- $value : mixed
Return values
string|nulllsub()
private
lsub(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringoptInt()
private
optInt(array<string, mixed> $opts, string $key) : int|null
Parameters
- $opts : array<string, mixed>
- $key : string
Return values
int|nullpersistedStockDocumentId()
private
persistedStockDocumentId(int $complaintId) : int|null
Parameters
- $complaintId : int
Return values
int|nulltransition()
private
transition(int $complaintId, int $newStatusId, array<string, mixed> $opts) : Result
Parameters
- $complaintId : int
- $newStatusId : int
- $opts : array<string, mixed>