CoolCMS API

ComplaintTransitionManager
in package
uses SourceLockTransactionTrait

FinalYes

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']

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).

Properties

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
Result

expireOverdue()

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: list}

isTransitionAllowed()

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
bool

acquireSourceLock()

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
bool

releaseSourceLock()

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
template
Return values
T

expireDueComplaintIds()

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>

ladd()

private ladd(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

ldiv()

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-string

lmul()

private lmul(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

lsub()

private lsub(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

optInt()

private optInt(array<string, mixed> $opts, string $key) : int|null
Parameters
$opts : array<string, mixed>
$key : string
Return values
int|null

persistedStockDocumentId()

private persistedStockDocumentId(int $complaintId) : int|null
Parameters
$complaintId : int
Return values
int|null

transition()

private transition(int $complaintId, int $newStatusId, array<string, mixed> $opts) : Result
Parameters
$complaintId : int
$newStatusId : int
$opts : array<string, mixed>
Return values
Result
On this page

Search results