CoolCMS API

DocumentService
in package

FinalYes

The single writer of the movement journal and batches.

Every operation is one raw-DBAL transaction wrapped in a deadlock retry (SQLSTATE 40001 / errno 1213). Every public operation returns just the raw document id (for adjust() the same result shape it always built internally). There is no find()/findAll() inside a transaction (it clears the unit of work), and the service never reaches back into its Manager to reload the managed entity (service to manager is a layer violation). The reload happens in the caller, StockDocumentManager, after this class returns.

Lock order (binding):

  • level rows are touched in ascending stock_item_id order;
  • the level row is the mutex for a stock item and warehouse: always upsert or FOR UPDATE the level first, only then the batches of that combination (batches are never a deadlock source);
  • a transfer locks its two levels in ascending warehouse_id order (both before any batch work), so A to B and B to A transfers order their locks identically and the wrapper retries any deadlock;
  • the upsert (INSERT ... ON DUPLICATE KEY UPDATE) is the only path that writes levels; a plain UPDATE on a missing row (transfer or inventory onto a fresh warehouse) affects 0 rows and would silently drop +q, so affected rows are asserted.

FEFO consumption: under the level lock, batches with quantity > 0 are read FOR UPDATE in order (expiration_date IS NULL), expiration_date, received_datetime, stock_batch_id and consumed in turn, with one stock_movement_batches pairing per consumed batch. movement.cogs_unit_price is sum(q * price) / sum(q) of the consumption. An explicit IssueLine.batchId consumes only that batch, which must belong to the same stock item and warehouse.

All money and quantity math is bcmath at scale 3. IMPORTANT: the currency direction is unit_price_base = round(unit_price / rate, 3) because the snapshot exchange_rate is foreign per 1 CZK; multiplying instead of dividing corrupts every base price.

issueFromReservationsInTransaction() lets ReservationService::confirm() build the posted issue document from a resolved reservation breakdown. The caller owns the transaction, the per-source GET_LOCK and the deadlock retry, so that entry takes the open Connection and never opens its own transaction.

Table of Contents

Constants

BATCHES  : string = 'cms_mod_store_stock_batches'
CURRENCIES  : string = 'cms_system_currencies'
DIV_SCALE  : int = 10
DOCUMENTS  : string = 'cms_mod_store_stock_documents'
ITEM_TEXTS  : string = 'cms_mod_store_stock_item_texts'
ITEMS  : string = 'cms_mod_store_stock_items'
LEVELS  : string = 'cms_mod_store_stock_levels'
MAX_ATTEMPTS  : int = 3
MOVEMENT_BATCHES  : string = 'cms_mod_store_stock_movement_batches'
MOVEMENTS  : string = 'cms_mod_store_stock_movements'
NUMBER_PREFIX  : array<string|int, mixed> = [\App\Core\Base\Shared\Shared\Models\Entities\S...
RATE_SCALE  : int = 6
SCALE  : int = 3
SETTING_MODULE  : int = 10
SETTINGS  : string = 'cms_system_settings'

Properties

$em  : EntityManagerInterface
$settingContext  : SettingContext

Methods

__construct()  : mixed
adjust()  : array{docId: ?int, warnings: list, rejected: list}
Inventory count (v1, per stock item). Per line under the level `FOR UPDATE`: if `snapshotQuantity` differs from the live quantity, the line goes to `rejectedLines` (the state changed meanwhile) and the rest still posts. `delta = counted - quantity`.
cancel()  : int
Cancels a posted document by creating a counter document of the same type with negated quantities: `source_type = 'storno'`, `source_module`/id carried from the original, two-way `storno_document_id` (UPDATE of the original plus status `CANCELLED`). An issue storno returns quantities to the original batches by the original pairing (`batch.quantity += q`, a negated pairing on the counter). A receipt storno consumes only the batches founded by the original document (`batch.movement_id` in the original movements); a consumed remnant refuses the whole storno. A transfer storno is a reverse transfer; an inventory storno is reverse delta movements. After each cancel the stock item average and last price are recomputed.
copyToConcept()  : int
Clones a posted or cancelled document header (without number, status, posted date and storno; `source_type` 'storno' becomes 'manual') and its movements (without `quantity_after` and cogs; keeping `batch_number`, expiration, unit price and currency) as a fresh draft (correction workflow).
issue()  : int
Manual issue without a reservation. Per line: conditional UPDATE `SET quantity = quantity - q WHERE ... AND quantity - reserved >= q`; 0 affected rows throws `InsufficientStockException` unless `negativeStockAllowed = 1` (then an unconditional decrement; FEFO consumes what it can and the uncovered remainder stays unpaired at cogs = `last_purchase_price` plus a Tracy warning). FEFO or an explicit `IssueLine.batchId` (manual batch pick). Optional sale-price snapshot per line.
issueFromReservationsInTransaction()  : int
Builds a posted issue document from an already-resolved reservation breakdown, inside a transaction owned by the caller (`ReservationService::confirm()`).
post()  : int
Posts a draft document (admin button). Validates (status `DRAFT` via raw DBAL; per line: the stock item exists, `qty > 0`, type-specific fields), applies the type effects (same logic as above; the lines are the draft's stored movements and a receipt reads `batch_number` and expiration from the movement), fills `quantity_after`, cogs, pairings and snapshots by updating the movements (the only permitted mutation of movements; they stay draft until the end of the post transaction), and assigns number, `posted_datetime` and status `POSTED` in the same transaction. A transfer draft stores only the `-q` source lines; `post()` generates the `+q` target movements.
receive()  : int
Receipt (posted immediately). Per line: `exchange_rate` snapshot and `unit_price_base = unit_price / rate` (divide, never multiply), upsert the level (`+q`), found a batch (number, expiration, price, received = now), refresh `last_purchase_price` and the weighted average of the remaining batches, `quantity_after` and item snapshots, then the document number. A return line (no `unitPrice`) prices its batch at the stock item's current average (falling back to the last price) and does not skew the average (batches without a price are excluded).
transfer()  : int
Transfer: one document, 2 movements per line: source `-q` (oversell guard and FEFO consumption) and target `+q` (upsert). Both levels are locked first in ascending `warehouse_id` order. For each consumed source layer a target batch is founded with the same `batch_number`, expiration and `unit_price_base` and the original `received_datetime` (FIFO age is preserved). Cogs on both movements is the weighted average of the consumption. Header warehouse is the source, `targetWarehouse` the target.
abs()  : numeric-string
add()  : numeric-string
applyAdjust()  : void
applyConfirmedIssueLine()  : void
Confirmed-issue applier: like `applyIssue()`, but the stock was reserved for this source, so instead of the conditional oversell guard it does the combined reservation release and physical decrement `reserved = GREATEST(0, reserved - q), quantity = quantity - q`. FEFO then consumes q. A shortfall can only arise when the physical quantity shrank after reserving (e.g. an inventory shortage posted with counted < reserved): physical reality wins, the uncovered part is valued at the last price and logged, and the invariant (sum of batches = max(level, 0)) stays whole.
applyInventoryDelta()  : void
Inventory post path: a draft inventory movement stores a signed delta (no snapshot check).
applyIssue()  : void
applyReceipt()  : void
applyTransfer()  : void
assertInvariant()  : void
After each operation asserts that the sum of `batch.quantity` equals `GREATEST(level.quantity, 0)` for every touched combination; a mismatch is a bug and rolls the transaction back. A negative level (only reachable via the negative-stock issue path) legally has zero batches; any later credit (receive, surplus, transfer-in, cancel return) absorbs the deficit first (`splitDeficitAbsorption()`) so this equality holds universally, with no per-combination exclusions.
assertPositive()  : void
baseCurrencyId()  : int
bcRound()  : numeric-string
Rounds a decimal string half-up to `$scale` (bcmath truncates, so add half a unit in the last place first).
cancelInventory()  : void
cancelIssue()  : void
cancelReceipt()  : void
cancelTransfer()  : void
Reverse transfer: consumes at the target only the batches founded by the original target movements and returns to the source the original consumed batches (per the source pairings).
cardPrices()  : array{avg: ?string, last: ?string}
cmp()  : int
cogsWithRemainder()  : string|null
Weighted cogs over the full issued quantity: the paired part from FEFO, the uncovered remainder (oversell) valued at `last_purchase_price`.
consumeFefo()  : array{consumed: string, unpaired: string, cogs: ?string, layers: list}
Consumes `$quantity` from a stock item and warehouse under the already-held level lock.
dec()  : numeric-string
decScale()  : numeric-string
div()  : numeric-string
ensureLevelLocked()  : array{quantity: string, reserved: string, id: ?int}
Ensures the level row exists and is X-locked (an upsert with delta 0 creates and locks, then `FOR UPDATE` re-reads under the lock).
finalizeNumber()  : void
insertBatch()  : int
insertDocument()  : int
insertDocumentRaw()  : int
insertMovement()  : int
insertPairing()  : void
itemExists()  : bool
markTouched()  : void
mul()  : numeric-string
n()  : numeric-string
neg()  : numeric-string
negativeStockAllowed()  : bool
rDate()  : string|null
Reads a date column as its 'Y-m-d' prefix, or null.
readLevel()  : array{quantity: string, reserved: string, id: ?int}
readLevelQuantity()  : string
recalcCard()  : void
Recomputes the stock item average (weighted average of remaining priced batches across all warehouses) and optionally `last_purchase_price`.
recalcLastPurchase()  : void
Recomputes `last_purchase_price` from the journal: the newest priced receipt movement of a posted, non-storno receipt document (the last valid purchase), or null when none remains. Used by `cancel()`, where the stornoed delivery's price must not stick on the stock item.
restoreBatchesFromPairings()  : string
Returns consumed quantities to their original batches per the original movement's pairings, capped at `$batchable` (deficit absorption). Writes the actual returned amount per batch as a negated pairing on the counter movement; the absorbed part gets no pairing (mirror of the unpaired oversell consumption that created the deficit). Returns the total actually returned.
rInt()  : int
rIntN()  : int|null
rStr()  : string
rStrN()  : string|null
runInTransaction()  : T
Runs a closure inside one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / 1213).
settingValue()  : string|null
Reads a module-10 setting. Prefers the request-populated `SettingContext` and falls back to a raw DBAL read. The fallback covers harness and cron contexts where the context is not initialized (accessing its uninitialized property throws, hence the guarded catch) and lets a live settings change (e.g. the `negativeStockAllowed` toggle) be honored even mid-request.
snapshotItem()  : array{0: string, 1: string}
sortByItem()  : array<int, T>
splitDeficitAbsorption()  : array{0: string, 1: string}
Splits a `+delta` level credit into the batchable part and the deficit-absorbed part: batchable = max(0, old + delta) - max(0, old). A negative level (`negativeStockAllowed` oversell debt) means `|old|` pieces were issued unpaired: an incoming credit first repays that debt (the absorbed part stays historically unpaired, mirroring the unpaired consumption) and only the batchable part may materialize as batch stock, keeping the invariant sum of batches = max(level, 0) universal.
sub()  : numeric-string
toBase()  : array{0: string, 1: string}
touchedItems()  : array<int, int>
upsertLevel()  : void

Constants

DOCUMENTS

private string DOCUMENTS = 'cms_mod_store_stock_documents'

ITEM_TEXTS

private string ITEM_TEXTS = 'cms_mod_store_stock_item_texts'

MOVEMENT_BATCHES

private string MOVEMENT_BATCHES = 'cms_mod_store_stock_movement_batches'

MOVEMENTS

private string MOVEMENTS = 'cms_mod_store_stock_movements'

NUMBER_PREFIX

private array<string|int, mixed> NUMBER_PREFIX = [\App\Core\Base\Shared\Shared\Models\Entities\StockDocumentType::RECEIPT => 'PRI', \App\Core\Base\Shared\Shared\Models\Entities\StockDocumentType::ISSUE => 'VYD', \App\Core\Base\Shared\Shared\Models\Entities\StockDocumentType::TRANSFER => 'PRE', \App\Core\Base\Shared\Shared\Models\Entities\StockDocumentType::INVENTORY => 'INV']

type => document number prefix

Properties

Methods

adjust()

Inventory count (v1, per stock item). Per line under the level `FOR UPDATE`: if `snapshotQuantity` differs from the live quantity, the line goes to `rejectedLines` (the state changed meanwhile) and the rest still posts. `delta = counted - quantity`.

public adjust(int $warehouseId, array<int, AdjustLine> $lines, string $note[, int|null $userId = null ]) : array{docId: ?int, warnings: list, rejected: list}

A shortage (delta < 0) triggers FEFO consumption of |delta| (the pairing prices the shortage, cogs) with no oversell guard against reserved (physical reality wins), and counted < reserved gives a warning. A surplus (delta > 0) creates a number-less batch priced at the average (falling back to the last price) and a +delta movement without pairing. delta = 0 skips the line (no movement).

Parameters
$warehouseId : int
$lines : array<int, AdjustLine>
$note : string
$userId : int|null = null
Return values
array{docId: ?int, warnings: list, rejected: list}

cancel()

Cancels a posted document by creating a counter document of the same type with negated quantities: `source_type = 'storno'`, `source_module`/id carried from the original, two-way `storno_document_id` (UPDATE of the original plus status `CANCELLED`). An issue storno returns quantities to the original batches by the original pairing (`batch.quantity += q`, a negated pairing on the counter). A receipt storno consumes only the batches founded by the original document (`batch.movement_id` in the original movements); a consumed remnant refuses the whole storno. A transfer storno is a reverse transfer; an inventory storno is reverse delta movements. After each cancel the stock item average and last price are recomputed.

public cancel(int $documentId[, int|null $userId = null ]) : int
Parameters
$documentId : int
$userId : int|null = null
Return values
int

copyToConcept()

Clones a posted or cancelled document header (without number, status, posted date and storno; `source_type` 'storno' becomes 'manual') and its movements (without `quantity_after` and cogs; keeping `batch_number`, expiration, unit price and currency) as a fresh draft (correction workflow).

public copyToConcept(int $documentId[, int|null $userId = null ]) : int
Parameters
$documentId : int
$userId : int|null = null
Return values
int

issue()

Manual issue without a reservation. Per line: conditional UPDATE `SET quantity = quantity - q WHERE ... AND quantity - reserved >= q`; 0 affected rows throws `InsufficientStockException` unless `negativeStockAllowed = 1` (then an unconditional decrement; FEFO consumes what it can and the uncovered remainder stays unpaired at cogs = `last_purchase_price` plus a Tracy warning). FEFO or an explicit `IssueLine.batchId` (manual batch pick). Optional sale-price snapshot per line.

public issue(int $warehouseId, array<int, IssueLine> $lines[, SourceRef|null $source = null ][, string|null $note = null ][, int|null $userId = null ]) : int
Parameters
$warehouseId : int
$lines : array<int, IssueLine>
$source : SourceRef|null = null
$note : string|null = null
$userId : int|null = null
Return values
int

issueFromReservationsInTransaction()

Builds a posted issue document from an already-resolved reservation breakdown, inside a transaction owned by the caller (`ReservationService::confirm()`).

public issueFromReservationsInTransaction(Connection $conn, SourceRef $source, array<int, array{itemId: int, warehouseId: int, quantity: string}> $rows, array<int, array{unitPrice: string|null, currencyId: int|null}> $salePrices[, int|null $userId = null ][, string|null $note = null ][, array<int, string> &$warnings = [] ]) : int

The caller holds the open $conn, the per-source GET_LOCK and the deadlock retry, and flips the reservation rows to confirmed in the same transaction. So this method opens no transaction of its own and does not reload or clear the EM (the caller reads what it needs after its own commit).

The header carries the SourceRef (source_type from the reservation source, e.g. 'order') so the generated column uq_stock_documents_confirm enforces "at most one posted issue per order" as a schema safety net behind the per-source lock. The header warehouse stays null: a reservation may waterfall across several warehouses, and the truth lives in the per-warehouse movements.

Per row (sorted by item, then warehouse, for the level-lock order) it does the combined reserved = GREATEST(0, reserved - q), quantity = quantity - q decrement (the stock was reserved for this source, so releasing the hold and removing the physical units happen together, with no oversell guard), a -q movement with the caller's sale-price snapshot, FEFO consumption, pairing and cogs, and a stock item average refresh. Returns the new document id (number prefix VYD-). See applyConfirmedIssueLine().

On a shortfall (the physical quantity shrank below what was reserved, e.g. an inventory shortage posted with counted < reserved) the decrement still proceeds: physical reality wins and the level may go negative (the negative-stock toggle does not apply here because the stock was reserved). The uncovered part is valued at the last price and that unpaired remainder is appended to $warnings so confirm() can surface it in ConfirmResult.warnings, never a silent negative. There is no clamp: clamping would break the quantity_after audit chain (sum of signed movements equals the level).

Parameters
$conn : Connection
$source : SourceRef
$rows : array<int, array{itemId: int, warehouseId: int, quantity: string}>
$salePrices : array<int, array{unitPrice: string|null, currencyId: int|null}>

keyed by stockItemId

$userId : int|null = null
$note : string|null = null
$warnings : array<int, string> = []

collects per-line unpaired notices (out-param)

Return values
int

post()

Posts a draft document (admin button). Validates (status `DRAFT` via raw DBAL; per line: the stock item exists, `qty > 0`, type-specific fields), applies the type effects (same logic as above; the lines are the draft's stored movements and a receipt reads `batch_number` and expiration from the movement), fills `quantity_after`, cogs, pairings and snapshots by updating the movements (the only permitted mutation of movements; they stay draft until the end of the post transaction), and assigns number, `posted_datetime` and status `POSTED` in the same transaction. A transfer draft stores only the `-q` source lines; `post()` generates the `+q` target movements.

public post(int $documentId) : int
Parameters
$documentId : int
Return values
int

receive()

Receipt (posted immediately). Per line: `exchange_rate` snapshot and `unit_price_base = unit_price / rate` (divide, never multiply), upsert the level (`+q`), found a batch (number, expiration, price, received = now), refresh `last_purchase_price` and the weighted average of the remaining batches, `quantity_after` and item snapshots, then the document number. A return line (no `unitPrice`) prices its batch at the stock item's current average (falling back to the last price) and does not skew the average (batches without a price are excluded).

public receive(int $warehouseId, array<int, ReceiveLine> $lines[, int|null $supplierId = null ][, SourceRef|null $source = null ][, string|null $note = null ][, int|null $userId = null ]) : int
Parameters
$warehouseId : int
$lines : array<int, ReceiveLine>
$supplierId : int|null = null
$source : SourceRef|null = null
$note : string|null = null
$userId : int|null = null
Return values
int

transfer()

Transfer: one document, 2 movements per line: source `-q` (oversell guard and FEFO consumption) and target `+q` (upsert). Both levels are locked first in ascending `warehouse_id` order. For each consumed source layer a target batch is founded with the same `batch_number`, expiration and `unit_price_base` and the original `received_datetime` (FIFO age is preserved). Cogs on both movements is the weighted average of the consumption. Header warehouse is the source, `targetWarehouse` the target.

public transfer(int $fromWarehouseId, int $toWarehouseId, array<int, IssueLine> $lines[, string|null $note = null ][, int|null $userId = null ]) : int
Parameters
$fromWarehouseId : int
$toWarehouseId : int
$lines : array<int, IssueLine>

(only stockItemId + quantity are used)

$note : string|null = null
$userId : int|null = null
Return values
int

abs()

private abs(string $value) : numeric-string
Parameters
$value : string
Return values
numeric-string

add()

private add(string $a, string $b[, int $scale = self::SCALE ]) : numeric-string
Parameters
$a : string
$b : string
$scale : int = self::SCALE
Return values
numeric-string

applyAdjust()

private applyAdjust(Connection $conn, int $documentId, AdjustLine $line, int $warehouseId, array<string, array{0: int, 1: int}> &$touched, array<int, string> &$warnings, array<int, array{stockItemId: int, reason: string}> &$rejected) : void
Parameters
$conn : Connection
$documentId : int
$line : AdjustLine
$warehouseId : int
$touched : array<string, array{0: int, 1: int}>
$warnings : array<int, string>
$rejected : array<int, array{stockItemId: int, reason: string}>

applyConfirmedIssueLine()

Confirmed-issue applier: like `applyIssue()`, but the stock was reserved for this source, so instead of the conditional oversell guard it does the combined reservation release and physical decrement `reserved = GREATEST(0, reserved - q), quantity = quantity - q`. FEFO then consumes q. A shortfall can only arise when the physical quantity shrank after reserving (e.g. an inventory shortage posted with counted < reserved): physical reality wins, the uncovered part is valued at the last price and logged, and the invariant (sum of batches = max(level, 0)) stays whole.

private applyConfirmedIssueLine(Connection $conn, int $documentId, int $itemId, int $warehouseId, string $quantity, array{unitPrice: string|null, currencyId: int|null}|null $salePrice, array<string, array{0: int, 1: int}> &$touched, array<int, string> &$warnings) : void
Parameters
$conn : Connection
$documentId : int
$itemId : int
$warehouseId : int
$quantity : string
$salePrice : array{unitPrice: string|null, currencyId: int|null}|null
$touched : array<string, array{0: int, 1: int}>
$warnings : array<int, string>

out-param; an unpaired remainder is appended here

applyInventoryDelta()

Inventory post path: a draft inventory movement stores a signed delta (no snapshot check).

private applyInventoryDelta(Connection $conn, int $movementId, int $itemId, int $warehouseId, string $delta, array<string, array{0: int, 1: int}> &$touched) : void
Parameters
$conn : Connection
$movementId : int
$itemId : int
$warehouseId : int
$delta : string
$touched : array<string, array{0: int, 1: int}>

applyIssue()

private applyIssue(Connection $conn, int $documentId, int|null $existingMovementId, int $itemId, int $warehouseId, string $quantity, string|null $salePrice, int|null $saleCurrencyId, int|null $explicitBatchId, array<string, array{0: int, 1: int}> &$touched) : void
Parameters
$conn : Connection
$documentId : int
$existingMovementId : int|null
$itemId : int
$warehouseId : int
$quantity : string
$salePrice : string|null
$saleCurrencyId : int|null
$explicitBatchId : int|null
$touched : array<string, array{0: int, 1: int}>

applyReceipt()

private applyReceipt(Connection $conn, int $documentId, int|null $existingMovementId, int $itemId, int $warehouseId, string $quantity, string|null $unitPrice, int|null $currencyId, string|null $batchNumber, string|null $expiration, array<string, array{0: int, 1: int}> &$touched) : void
Parameters
$conn : Connection
$documentId : int
$existingMovementId : int|null
$itemId : int
$warehouseId : int
$quantity : string
$unitPrice : string|null
$currencyId : int|null
$batchNumber : string|null
$expiration : string|null
$touched : array<string, array{0: int, 1: int}>

applyTransfer()

private applyTransfer(Connection $conn, int $documentId, int $itemId, int $fromWarehouseId, int $toWarehouseId, string $quantity, int|null $existingSourceMovementId, array<string, array{0: int, 1: int}> &$touched) : void
Parameters
$conn : Connection
$documentId : int
$itemId : int
$fromWarehouseId : int
$toWarehouseId : int
$quantity : string
$existingSourceMovementId : int|null
$touched : array<string, array{0: int, 1: int}>

assertInvariant()

After each operation asserts that the sum of `batch.quantity` equals `GREATEST(level.quantity, 0)` for every touched combination; a mismatch is a bug and rolls the transaction back. A negative level (only reachable via the negative-stock issue path) legally has zero batches; any later credit (receive, surplus, transfer-in, cancel return) absorbs the deficit first (`splitDeficitAbsorption()`) so this equality holds universally, with no per-combination exclusions.

private assertInvariant(Connection $conn, array<string, array{0: int, 1: int}> $touched) : void
Parameters
$conn : Connection
$touched : array<string, array{0: int, 1: int}>

assertPositive()

private assertPositive(string $quantity, string $label) : void
Parameters
$quantity : string
$label : string

bcRound()

Rounds a decimal string half-up to `$scale` (bcmath truncates, so add half a unit in the last place first).

private bcRound(string $value, int $scale) : numeric-string
Parameters
$value : string
$scale : int
Return values
numeric-string

cancelInventory()

private cancelInventory(Connection $conn, int $counterId, array<int, array<string, mixed>> $origMovements, array<string, array{0: int, 1: int}> &$touched) : void
Parameters
$conn : Connection
$counterId : int
$origMovements : array<int, array<string, mixed>>
$touched : array<string, array{0: int, 1: int}>

cancelIssue()

private cancelIssue(Connection $conn, int $counterId, array<int, array<string, mixed>> $origMovements, array<string, array{0: int, 1: int}> &$touched) : void
Parameters
$conn : Connection
$counterId : int
$origMovements : array<int, array<string, mixed>>
$touched : array<string, array{0: int, 1: int}>

cancelReceipt()

private cancelReceipt(Connection $conn, int $counterId, array<int, array<string, mixed>> $origMovements, array<string, array{0: int, 1: int}> &$touched) : void
Parameters
$conn : Connection
$counterId : int
$origMovements : array<int, array<string, mixed>>
$touched : array<string, array{0: int, 1: int}>

cancelTransfer()

Reverse transfer: consumes at the target only the batches founded by the original target movements and returns to the source the original consumed batches (per the source pairings).

private cancelTransfer(Connection $conn, int $counterId, array<int, array<string, mixed>> $origMovements, int|null $fromWarehouseId, int|null $toWarehouseId, array<string, array{0: int, 1: int}> &$touched) : void
Parameters
$conn : Connection
$counterId : int
$origMovements : array<int, array<string, mixed>>
$fromWarehouseId : int|null
$toWarehouseId : int|null
$touched : array<string, array{0: int, 1: int}>

cardPrices()

private cardPrices(Connection $conn, int $itemId) : array{avg: ?string, last: ?string}
Parameters
$conn : Connection
$itemId : int
Return values
array{avg: ?string, last: ?string}

cmp()

private cmp(string $a, string $b[, int $scale = self::SCALE ]) : int
Parameters
$a : string
$b : string
$scale : int = self::SCALE
Return values
int

cogsWithRemainder()

Weighted cogs over the full issued quantity: the paired part from FEFO, the uncovered remainder (oversell) valued at `last_purchase_price`.

private cogsWithRemainder(Connection $conn, int $itemId, array{consumed: string, unpaired: string, cogs: ?string, layers: list>} $consumption, string $quantity) : string|null
Parameters
$conn : Connection
$itemId : int
$consumption : array{consumed: string, unpaired: string, cogs: ?string, layers: list>}
$quantity : string
Return values
string|null

consumeFefo()

Consumes `$quantity` from a stock item and warehouse under the already-held level lock.

private consumeFefo(Connection $conn, int $movementId, int $itemId, int $warehouseId, string $quantity, int|null $explicitBatchId) : array{consumed: string, unpaired: string, cogs: ?string, layers: list}

Updates batch quantities, inserts one pairing per consumed batch, and returns the consumption layers, cogs and the uncovered remainder (unpaired, only reachable via oversell).

Parameters
$conn : Connection
$movementId : int
$itemId : int
$warehouseId : int
$quantity : string
$explicitBatchId : int|null
Return values
array{consumed: string, unpaired: string, cogs: ?string, layers: list}

dec()

private dec(string $value) : numeric-string
Parameters
$value : string
Return values
numeric-string

decScale()

private decScale(string $value, int $scale) : numeric-string
Parameters
$value : string
$scale : int
Return values
numeric-string

div()

private div(string $a, string $b[, int $scale = self::SCALE ]) : numeric-string
Parameters
$a : string
$b : string
$scale : int = self::SCALE
Return values
numeric-string

ensureLevelLocked()

Ensures the level row exists and is X-locked (an upsert with delta 0 creates and locks, then `FOR UPDATE` re-reads under the lock).

private ensureLevelLocked(Connection $conn, int $itemId, int $warehouseId) : array{quantity: string, reserved: string, id: ?int}
Parameters
$conn : Connection
$itemId : int
$warehouseId : int
Return values
array{quantity: string, reserved: string, id: ?int}

finalizeNumber()

private finalizeNumber(Connection $conn, int $documentId, int $type) : void
Parameters
$conn : Connection
$documentId : int
$type : int

insertBatch()

private insertBatch(Connection $conn, int $itemId, int $warehouseId, string|null $batchNumber, string|null $expiration, string $quantity, string|null $unitPriceBase, string|null $receivedDatetime, int $movementId) : int
Parameters
$conn : Connection
$itemId : int
$warehouseId : int
$batchNumber : string|null
$expiration : string|null
$quantity : string
$unitPriceBase : string|null
$receivedDatetime : string|null
$movementId : int
Return values
int

insertDocument()

private insertDocument(Connection $conn, int $type, int|null $warehouseId, int|null $targetWarehouseId, int|null $supplierId, SourceRef|null $source, string|null $note, int|null $userId) : int
Parameters
$conn : Connection
$type : int
$warehouseId : int|null
$targetWarehouseId : int|null
$supplierId : int|null
$source : SourceRef|null
$note : string|null
$userId : int|null
Return values
int

insertDocumentRaw()

private insertDocumentRaw(Connection $conn, int $type, int $status, int|null $warehouseId, int|null $targetWarehouseId, int|null $supplierId, int|null $sourceModule, string|null $sourceType, int|null $sourceId, int|null $stornoDocumentId, string|null $note, int|null $userId[, bool $posted = true ]) : int
Parameters
$conn : Connection
$type : int
$status : int
$warehouseId : int|null
$targetWarehouseId : int|null
$supplierId : int|null
$sourceModule : int|null
$sourceType : string|null
$sourceId : int|null
$stornoDocumentId : int|null
$note : string|null
$userId : int|null
$posted : bool = true
Return values
int

insertMovement()

private insertMovement(Connection $conn, array{document: int, item: int, warehouse: int, quantity: string, quantity_after: ?string, item_code: string, item_name: string, batch_number: ?string, expiration: ?string, unit_price: ?string, currency: ?int, rate: ?string, unit_price_base: ?string, cogs: ?string} $d) : int
Parameters
$conn : Connection
$d : array{document: int, item: int, warehouse: int, quantity: string, quantity_after: ?string, item_code: string, item_name: string, batch_number: ?string, expiration: ?string, unit_price: ?string, currency: ?int, rate: ?string, unit_price_base: ?string, cogs: ?string}
Return values
int

insertPairing()

private insertPairing(Connection $conn, int $movementId, int|null $batchId, string $quantity, string|null $unitPriceBase, string|null $batchNumber) : void
Parameters
$conn : Connection
$movementId : int
$batchId : int|null
$quantity : string
$unitPriceBase : string|null
$batchNumber : string|null

itemExists()

private itemExists(Connection $conn, int $itemId) : bool
Parameters
$conn : Connection
$itemId : int
Return values
bool

markTouched()

private markTouched(array<string, array{0: int, 1: int}> &$touched, int $itemId, int $warehouseId) : void
Parameters
$touched : array<string, array{0: int, 1: int}>
$itemId : int
$warehouseId : int

mul()

private mul(string $a, string $b[, int $scale = self::SCALE ]) : numeric-string
Parameters
$a : string
$b : string
$scale : int = self::SCALE
Return values
numeric-string

n()

private n(string $value) : numeric-string
Parameters
$value : string
Return values
numeric-string

neg()

private neg(string $value) : numeric-string
Parameters
$value : string
Return values
numeric-string

negativeStockAllowed()

private negativeStockAllowed() : bool
Return values
bool

rDate()

Reads a date column as its 'Y-m-d' prefix, or null.

private rDate(mixed $value) : string|null
Parameters
$value : mixed
Return values
string|null

readLevel()

private readLevel(Connection $conn, int $itemId, int $warehouseId[, bool $forUpdate = false ]) : array{quantity: string, reserved: string, id: ?int}
Parameters
$conn : Connection
$itemId : int
$warehouseId : int
$forUpdate : bool = false
Return values
array{quantity: string, reserved: string, id: ?int}

readLevelQuantity()

private readLevelQuantity(Connection $conn, int $itemId, int $warehouseId) : string
Parameters
$conn : Connection
$itemId : int
$warehouseId : int
Return values
string

recalcCard()

Recomputes the stock item average (weighted average of remaining priced batches across all warehouses) and optionally `last_purchase_price`.

private recalcCard(Connection $conn, int $itemId, string|null $lastPrice) : void
Parameters
$conn : Connection
$itemId : int
$lastPrice : string|null

recalcLastPurchase()

Recomputes `last_purchase_price` from the journal: the newest priced receipt movement of a posted, non-storno receipt document (the last valid purchase), or null when none remains. Used by `cancel()`, where the stornoed delivery's price must not stick on the stock item.

private recalcLastPurchase(Connection $conn, int $itemId) : void
Parameters
$conn : Connection
$itemId : int

restoreBatchesFromPairings()

Returns consumed quantities to their original batches per the original movement's pairings, capped at `$batchable` (deficit absorption). Writes the actual returned amount per batch as a negated pairing on the counter movement; the absorbed part gets no pairing (mirror of the unpaired oversell consumption that created the deficit). Returns the total actually returned.

private restoreBatchesFromPairings(Connection $conn, int $counterMovementId, int $origMovementId, string $batchable) : string
Parameters
$conn : Connection
$counterMovementId : int
$origMovementId : int
$batchable : string
Return values
string

rInt()

private rInt(mixed $value) : int
Parameters
$value : mixed
Return values
int

rIntN()

private rIntN(mixed $value) : int|null
Parameters
$value : mixed
Return values
int|null

rStr()

private rStr(mixed $value) : string
Parameters
$value : mixed
Return values
string

rStrN()

private rStrN(mixed $value) : string|null
Parameters
$value : mixed
Return values
string|null

runInTransaction()

Runs a closure inside one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / 1213).

private runInTransaction(callable(Connection): T $fn) : T

The transaction is READ COMMITTED (per transaction, auto-reverting). These operations lock the level row first as the stock item and warehouse mutex, then consume and insert its batches under that lock. Under the session default REPEATABLE READ a locking read only sees the latest version of rows already in the transaction's read view: a batch row inserted by a concurrently committed peer (the target side of a parallel transfer, or a receipt) is a brand-new row outside that view and stays invisible even to SELECT ... FOR UPDATE, while the level (an existing row) reads current. The two diverge and give a spurious "invariant broken" on e.g. parallel transfers A to B and B to A. READ COMMITTED gives every statement a fresh read view, so consumeFefo() sees exactly what the level mutex protects. The level X-lock still serializes writers in both isolations. Only DocumentService needs this; ReservationService keeps REPEATABLE READ for its conditional-UPDATE gap-lock guards.

Parameters
$fn : callable(Connection): T
Tags
template
Return values
T

settingValue()

Reads a module-10 setting. Prefers the request-populated `SettingContext` and falls back to a raw DBAL read. The fallback covers harness and cron contexts where the context is not initialized (accessing its uninitialized property throws, hence the guarded catch) and lets a live settings change (e.g. the `negativeStockAllowed` toggle) be honored even mid-request.

private settingValue(string $key) : string|null
Parameters
$key : string
Return values
string|null

snapshotItem()

private snapshotItem(Connection $conn, int $itemId) : array{0: string, 1: string}
Parameters
$conn : Connection
$itemId : int
Return values
array{0: string, 1: string} —

[code, name]

sortByItem()

private sortByItem(array<int, T> $lines) : array<int, T>
Parameters
$lines : array<int, T>
Tags
template
Return values
array<int, T>

splitDeficitAbsorption()

Splits a `+delta` level credit into the batchable part and the deficit-absorbed part: batchable = max(0, old + delta) - max(0, old). A negative level (`negativeStockAllowed` oversell debt) means `|old|` pieces were issued unpaired: an incoming credit first repays that debt (the absorbed part stays historically unpaired, mirroring the unpaired consumption) and only the batchable part may materialize as batch stock, keeping the invariant sum of batches = max(level, 0) universal.

private splitDeficitAbsorption(string $oldLevel, string $delta) : array{0: string, 1: string}
Parameters
$oldLevel : string
$delta : string
Return values
array{0: string, 1: string} —

[batchable, absorbed]

sub()

private sub(string $a, string $b[, int $scale = self::SCALE ]) : numeric-string
Parameters
$a : string
$b : string
$scale : int = self::SCALE
Return values
numeric-string

toBase()

private toBase(Connection $conn, string $unitPrice, int $currencyId, int $baseCurrencyId) : array{0: string, 1: string}
Parameters
$conn : Connection
$unitPrice : string
$currencyId : int
$baseCurrencyId : int
Return values
array{0: string, 1: string} —

[rate (scale 6), base price (scale 3)]

touchedItems()

private touchedItems(array<string, array{0: int, 1: int}> $touched) : array<int, int>
Parameters
$touched : array<string, array{0: int, 1: int}>
Return values
array<int, int>

upsertLevel()

private upsertLevel(Connection $conn, int $itemId, int $warehouseId, string $delta) : void
Parameters
$conn : Connection
$itemId : int
$warehouseId : int
$delta : string
On this page

Search results