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()); $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; 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 = $request->getDataInt('stock') ?? 1; 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 */ 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/item') ->with('supplier') ->with('client') ->where('id', $isBillElement ? $new->bill->id : $new->id) ->execute(); // Has stock movement? if (!$bill->type->transferStock) { return; } $billElements = $isBillElement ? [$new] : $bill->elements; // @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 ($billElements 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 : '') ->execute(); $transaction = new StockTransaction(); // @todo how to handle only reserving items for drafted bills (not yet shipped) if ($trigger === 'POST:Module:Billing-bill_element-create') { // Check stock availability if ($bill->type->sign < 0 && $dist->quantity < $element->quantity) { continue; } /** @var \Modules\Billing\Models\BillElement $new */ // Handle stock quantity ///////////////////////////////////////////////////////////////// // @todo handle stock returns!!! if ($bill->type->sign < 0) { $dist->quantity -= $element->quantity; StockDistributionMapper::update()->execute($dist); } else { if ($dist->id === 0) { $dist = new StockDistribution(); $dist->item = $element->item->id; $dist->quantity = $element->quantity; $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; StockDistributionMapper::update()->execute($dist); } } // Handle transfer protocol ///////////////////////////////////////////////////////////////// $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->getQuantity(); // @todo may require split quantity if not sufficient available from one lost // @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 // Handle to 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::PURCHASE; } } else { // Handle from 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 if ($bill->type->transferType === BillTransferType::SALES || $bill->type->transferType === BillTransferType::PURCHASE ) { $transaction->subtype = StockTransactionType::RETURN; } } StockTransactionMapper::create()->execute($transaction); } elseif ($trigger === 'POST:Module: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) ->execute(); /* 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 !== $old->item) { StockTransactionMapper::delete()->execute($transactions); $this->eventBillUpdateInternal( $account, $old, $new, $type, 'POST:Module:Billing-bill_element-create', $module, $ref, $content, $ip ); } } elseif ($trigger === 'POST:Module:Billing-bill_element-delete') { /** @var \Modules\Billing\Models\BillElement $new */ /** @var \Modules\WarehouseManagement\Models\StockTransaction[] $transactions */ $transactions = StockTransactionMapper::getAll() ->where('billElement', $new->id) ->execute(); StockTransactionMapper::delete()->execute($transactions); } elseif ($trigger === 'POST:Module:Billing-bill-delete') { /** @var \Modules\Billing\Models\Bill $new */ /** @var \Modules\Billing\Models\Bill $bill */ $bill = BillMapper::get() ->with('type') ->with('elements') ->with('supplier') ->with('client') ->where('id', $new->id) ->execute(); foreach ($bill->elements as $element) { /** @var \Modules\WarehouseManagement\Models\StockTransaction[] $transactions */ $transactions = StockTransactionMapper::getAll() ->where('billElement', $element->id) ->execute(); StockTransactionMapper::delete()->execute($transactions); // @todo consider not to delete but mark as deleted? } } elseif ($trigger === 'POST:Module: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, 'POST:Module:Billing-bill-delete', $module, $ref, $content, $ip ); } elseif ($new->status === BillStatus::ARCHIVED) { /** @var \Modules\Billing\Models\Bill $bill */ $bill = BillMapper::get() ->with('type') ->with('elements') ->with('supplier') ->with('client') ->where('id', $new->id) ->execute(); foreach ($bill->elements as $element) { /** @var \Modules\WarehouseManagement\Models\StockTransaction[] $transactions */ $transactions = StockTransactionMapper::getAll() ->where('billElement', $element->id) ->execute(); foreach ($transactions as $transaction) { $transaction->state = StockTransactionState::TRANSIT; // @todo change to more specific StockTransactionMapper::update()->execute($transaction); } } } } } } }