validateStockCreate($request))) { $response->header->status = RequestStatusCode::R_400; $this->createInvalidCreateResponse($request, $response, $val); return; } $stock = $this->createStockFromRequest($request); $this->createModel($request->header->account, $stock, StockMapper::class, 'stock', $request->getOrigin()); $request->setData('name', $request->getDataString('name') . '-1', true); $request->setData('stock', $stock->id, true); $stock = $this->createStockLocationFromRequest($request); $this->createModel($request->header->account, $stock, StockLocationMapper::class, 'stocklocation', $request->getOrigin()); $this->createStandardCreateResponse($request, $response, $stock); } /** * Validate stock create request * * @param RequestAbstract $request Request * * @return array * * @since 1.0.0 */ private function validateStockCreate(RequestAbstract $request) : array { $val = []; if (($val['name'] = !$request->hasData('name')) ) { return $val; } return []; } /** * Method to create stock from request. * * @param RequestAbstract $request Request * * @return Stock * * @since 1.0.0 */ private function createStockFromRequest(RequestAbstract $request) : Stock { $stock = new Stock(); $stock->name = $request->getDataString('name') ?? ''; $stock->unit = $request->getDataInt('unit') ?? 1; $stock->inventory = $request->getDataBool('inventory') ?? false; $stock->client = $request->hasData('client') ? new NullClient((int) $request->getData('client')) : null; $stock->supplier = $request->hasData('supplier') ? new NullSupplier((int) $request->getData('supplier')) : null; return $stock; } /** * Api method to create stock * * @param RequestAbstract $request Request * @param ResponseAbstract $response Response * @param array $data Generic data * * @return void * * @api * * @since 1.0.0 */ public function apiStockLocationCreate(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void { if (!empty($val = $this->validateStockLocationCreate($request))) { $response->header->status = RequestStatusCode::R_400; $this->createInvalidCreateResponse($request, $response, $val); return; } $stock = $this->createStockLocationFromRequest($request); $this->createModel($request->header->account, $stock, StockLocationMapper::class, 'stocklocation', $request->getOrigin()); $this->createStandardCreateResponse($request, $response, $stock); } /** * Validate stock create request * * @param RequestAbstract $request Request * * @return array * * @since 1.0.0 */ private function validateStockLocationCreate(RequestAbstract $request) : array { $val = []; if (($val['name'] = !$request->hasData('name')) || ($val['stock'] = !$request->hasData('stock')) ) { return $val; } return []; } /** * Method to create stock from request. * * @param RequestAbstract $request Request * * @return StockLocation * * @since 1.0.0 */ private function createStockLocationFromRequest(RequestAbstract $request) : StockLocation { $location = new StockLocation(); $location->name = $request->getDataString('name') ?? ''; $location->stock = new NullStock($request->getDataInt('stock') ?? 1); $location->type = $request->hasData('type') ? new NullStockType((int) $request->getDataInt('type')) : null; return $location; } /** * Event after creating a stock * * @param int $account Account * @param mixed $old Old stock model * @param mixed $new New / created stock model * @param null|int $type Event type (usually mapper hash) * @param string $trigger Trigger name * @param null|string $module Module name who triggers the event * @param null|string $ref Reference (e.g. reference to a different model) * @param null|string $content Content for the event (e.g. comment, values, ...) * @param null|string $ip Ip of the account * * @return void * * @since 1.0.0 */ public function eventStockCreateInternal( int $account, mixed $old, mixed $new, ?int $type = null, string $trigger = '', ?string $module = null, ?string $ref = null, ?string $content = null, ?string $ip = null ) : void { /** @var \Modules\ClientManagement\Models\Client|\Modules\SupplierManagement\Models\Supplier $new */ $stock = new Stock($new->number); StockMapper::create()->execute($stock); $stockLocation = new StockLocation($stock->name . '-1'); $stockLocation->stock = $stock->id; StockLocationMapper::create()->execute($stockLocation); $stockShelf = new StockShelf($stockLocation->name . '-1'); $stockShelf->location = $stockLocation->id; StockShelfMapper::create()->execute($stockShelf); } /** * Event after doing anything with a bill * * @param int $account Account * @param mixed $old Old bill model * @param mixed $new New / created bill model * @param int $type Event type (usually mapper hash) * @param string $trigger Trigger name * @param null|string $module Module name who triggers the event * @param null|string $ref Reference (e.g. reference to a different model) * @param null|string $content Content for the event (e.g. comment, values, ...) * @param null|string $ip Ip of the account * * @return void * * @since 1.0.0 * * @todo Cleanup/restructure so this function works with database transactions and exceptions. This function is very critical! * Maybe do the transaction outside wherever the updateModel/createModel/... functions are called. */ public function eventBillUpdateInternal( int $account, mixed $old, mixed $new, ?int $type = null, string $trigger = '', ?string $module = null, ?string $ref = null, ?string $content = null, ?string $ip = null ) : void { // Directly/manually creating a transaction is handled in the API Create/Update functions. $isBillElement = $new instanceof BillElement; /** @var \Modules\Billing\Models\Bill|\Modules\Billing\Models\BillElement $new */ /** @var \Modules\Billing\Models\Bill $bill */ $bill = BillMapper::get() ->with('type') ->with('elements') ->with('elements/container') ->with('elements/item') ->with('supplier') ->with('client') ->where('id', $isBillElement ? $new->bill->id : $new->id) ->where('type/transferStock', true) ->execute(); // Has stock movement? if ($bill->id === 0 || !$bill->type->transferStock) { return; } /* Only necessary if actual client/supplier stock $externalStock = 1; if ($bill->client !== null) { $externalStock = StockMapper::get() ->where('client', $bill->client->id) ->limit(1) ->execute(); } elseif ($bill->supplier !== null) { $externalStock = StockMapper::get() ->where('supplier', $bill->supplier->id) ->limit(1) ->execute(); } */ // @todo check if old element existed -> removed/changed item // @todo we cannot have transaction->to and transaction->from be the id of client/supplier because the IDs can overlap // @todo How to differentiate between stock movement // invoice with prior delivery note(s), // invoice with partly delivery note(s), // invoice with no delivery note // @todo Handle bill drafts (now only finalization moves stock, how do we reserve stock?) foreach ($bill->elements as $element) { if ($element->item === 0 || $element->item === null || $element->item->stockIdentifier === StockIdentifierType::NONE ) { continue; } $dist = StockDistributionMapper::get() ->where('item', $element->item->id) ->where('stock', 1) // @todo fix ->where('stockType', 1) // @todo fix ->where('lot', $element->item->stockIdentifier === StockIdentifierType::NUMBER ? null : '') ->limit(1) ->execute(); // @todo how to handle only reserving items for drafted bills (not yet shipped) if ($trigger === 'Billing-bill_element-create') { // Check stock availability if ($bill->type->sign > 0 && $dist->quantity < $element->quantity->getInt()) { continue; } /** @var \Modules\Billing\Models\BillElement $new */ // Handle stock quantity ///////////////////////////////////////////////////////////////// // @todo handle stock returns!!! if ($bill->type->sign > 0) { $dist->quantity -= $element->quantity->getInt(); StockDistributionMapper::update()->execute($dist); } elseif ($dist->id === 0) { $dist = new StockDistribution(); $dist->item = $element->item->id; $dist->quantity = $element->quantity->getInt(); $dist->lot = null; // @todo handle correct $dist->stock = 1; // @todo handle correct $dist->stockType = 1; // @todo handle correct StockDistributionMapper::create()->execute($dist); } else { $dist->quantity += $element->quantity->getInt(); StockDistributionMapper::update()->execute($dist); } // Handle transfer protocol ///////////////////////////////////////////////////////////////// $transaction = new StockTransaction(); $transaction->billElement = $new->id; $transaction->state = StockTransactionState::DRAFT; // @todo load default stock movement for bill type/organization settings (default stock location, default lot order e.g. FIFO/LIFO) // @todo find stock candidates $transaction->type = StockTransactionType::TRANSFER; // @todo depends on bill type $transaction->quantity = $new->quantity->getInt(); // @todo may require split quantity if not sufficient available from one lost $transaction->item = $element->item->id; // @todo allow consignment bills // @todo allow to pass stocklocation for entire bill to avoid re-defining it // @todo allow custom stock location if ($bill->type->sign > 0) { // Handle from // @todo find possible candidate based on defined default stock for bill type/org/location $transaction->fromStock = 1; // @todo requires update $transaction->fromStockType = 1; // @todo requires update // Handle to $transaction->toStock = null; // @todo requires update $transaction->toStockType = null; // @todo requires update if (($bill->client?->id ?? 0) !== 0) { // @todo remove phpstan this is just a bug fix until phpstan fixes this bug /** @phpstan-ignore-next-line */ $transaction->to = $bill->client->id; } elseif (($bill->supplier?->id ?? 0) !== 0) { // @todo remove phpstan this is just a bug fix until phpstan fixes this bug /** @phpstan-ignore-next-line */ $transaction->to = $bill->supplier->id; } if ($bill->type->transferType === BillTransferType::SALES) { $transaction->subtype = StockTransactionType::SALE; } elseif ($bill->type->transferType === BillTransferType::PURCHASE) { $transaction->subtype = StockTransactionType::RETURN; } } else { // Handle from $transaction->fromStock = null; // @todo requires update $transaction->fromStockType = null; // @todo requires update if (($bill->client?->id ?? 0) !== 0) { // @todo remove phpstan this is just a bug fix until phpstan fixes this bug /** @phpstan-ignore-next-line */ $transaction->from = $bill->client->id; } elseif (($bill->supplier?->id ?? 0) !== 0) { // @todo remove phpstan this is just a bug fix until phpstan fixes this bug /** @phpstan-ignore-next-line */ $transaction->from = $bill->supplier->id; } // Handle to // @todo find possible candidate based on defined default stock for bill type/org/location $transaction->toStock = 1; // @todo requires update $transaction->toStockType = 1; // @todo requires update if ($bill->type->transferType === BillTransferType::SALES) { $transaction->subtype = StockTransactionType::RETURN; } elseif ($bill->type->transferType === BillTransferType::PURCHASE) { $transaction->subtype = StockTransactionType::PURCHASE; } } StockTransactionMapper::create()->execute($transaction); } elseif ($trigger === 'Billing-bill_element-update') { /** @var \Modules\Billing\Models\BillElement $new */ /** @var \Modules\Billing\Models\BillElement $old */ /** @var \Modules\WarehouseManagement\Models\StockTransaction[] $transactions */ $transactions = StockTransactionMapper::getAll() ->where('billElement', $new->id) ->executeGetArray(); /* if ($new->item === $old->item) { // quantity change // lot changes // stock changes // all other changes ignore! // check availability again, if not available abort bill // maybe from an algorithmic point of view first set quantity to zero // and then do normal algorithm like for a new element } */ if ($new->item?->id !== $old->item?->id) { // @todo: also undo stock amount in stock distribution StockTransactionMapper::delete()->execute($transactions); $this->eventBillUpdateInternal( $account, $old, $new, $type, 'Billing-bill_element-create', $module, $ref, $content, $ip ); } // @todo handle same item but quantity update } elseif ($trigger === 'Billing-bill_element-delete') { /** @var \Modules\Billing\Models\BillElement $new */ /** @var \Modules\WarehouseManagement\Models\StockTransaction[] $transactions */ $transactions = StockTransactionMapper::getAll() ->where('billElement', $new->id) ->executeGetArray(); StockTransactionMapper::delete()->execute($transactions); } elseif ($trigger === 'Billing-bill-delete') { foreach ($bill->elements as $element) { /** @var \Modules\WarehouseManagement\Models\StockTransaction[] $transactions */ $transactions = StockTransactionMapper::getAll() ->where('billElement', $element->id) ->executeGetArray(); StockTransactionMapper::delete()->execute($transactions); // @todo consider not to delete but mark as deleted? } } elseif ($trigger === 'Billing-bill-update') { // is receiver update -> change all movements // is status update -> change all movements (delete = delete) /** @var \Modules\Billing\Models\Bill $new */ if ($new->status === BillStatus::DELETED) { $this->eventBillUpdateInternal( $account, $old, $new, $type, 'Billing-bill-delete', $module, $ref, $content, $ip ); } elseif ($new->status === BillStatus::ARCHIVED) { foreach ($bill->elements as $element) { /** @var \Modules\WarehouseManagement\Models\StockTransaction[] $transactions */ $transactions = StockTransactionMapper::getAll() ->where('billElement', $element->id) ->executeGetArray(); foreach ($transactions as $transaction) { $transaction->state = StockTransactionState::TRANSIT; // @todo change to more specific StockTransactionMapper::update()->execute($transaction); } } } } } } }