diff --git a/app/Crud/ProcurementProductCrud.php b/app/Crud/ProcurementProductCrud.php new file mode 100644 index 000000000..8aa7486c9 --- /dev/null +++ b/app/Crud/ProcurementProductCrud.php @@ -0,0 +1,457 @@ + false, + 'read' => true, + 'update' => true, + 'delete' => false, // cannot be deleted + ]; + + /** + * Adding relation + * Example : [ 'nexopos_users as user', 'user.id', '=', 'nexopos_orders.author' ] + * @param array + */ + public $relations = [ + [ 'nexopos_procurements as procurement', 'procurement.id', '=', 'nexopos_procurements_products.procurement_id' ], + [ 'nexopos_units as unit', 'unit.id', '=', 'nexopos_procurements_products.unit_id' ], + [ 'nexopos_users as user', 'user.id', '=', 'nexopos_procurements_products.author' ], + ]; + + /** + * all tabs mentionned on the tabs relations + * are ignored on the parent model. + */ + protected $tabsRelations = [ + // 'tab_name' => [ YourRelatedModel::class, 'localkey_on_relatedmodel', 'foreignkey_on_crud_model' ], + ]; + + /** + * Pick + * Restrict columns you retreive from relation. + * Should be an array of associative keys, where + * keys are either the related table or alias name. + * Example : [ + * 'user' => [ 'username' ], // here the relation on the table nexopos_users is using "user" as an alias + * ] + */ + public $pick = [ + 'procurement' => [ 'name' ], + 'unit' => [ 'name' ], + 'user' => [ 'username' ], + ]; + + /** + * Define where statement + * @var array + **/ + protected $listWhere = []; + + /** + * Define where in statement + * @var array + */ + protected $whereIn = []; + + /** + * Fields which will be filled during post/put + */ + public $fillable = []; + + /** + * Define Constructor + * @param + */ + public function __construct() + { + parent::__construct(); + + Hook::addFilter( $this->namespace . '-crud-actions', [ $this, 'setActions' ], 10, 2 ); + } + + /** + * Return the label used for the crud + * instance + * @return array + **/ + public function getLabels() + { + return [ + 'list_title' => __( 'Procurement Products List' ), + 'list_description' => __( 'Display all procurement products.' ), + 'no_entry' => __( 'No procurement products has been registered' ), + 'create_new' => __( 'Add a new procurement product' ), + 'create_title' => __( 'Create a new procurement product' ), + 'create_description' => __( 'Register a new procurement product and save it.' ), + 'edit_title' => __( 'Edit procurement product' ), + 'edit_description' => __( 'Modify Procurement Product.' ), + 'back_to_list' => __( 'Return to Procurement Products' ), + ]; + } + + /** + * Check whether a feature is enabled + * @return boolean + **/ + public function isEnabled( $feature ) + { + return false; // by default + } + + /** + * Fields + * @param object/null + * @return array of field + */ + public function getForm( $entry = null ) + { + return [ + 'main' => [ + 'label' => __( 'Name' ), + 'name' => 'name', + 'value' => $entry->name ?? '', + 'description' => __( 'Provide a name to the resource.' ) + ], + 'tabs' => [ + 'general' => [ + 'label' => __( 'General' ), + 'fields' => [ + [ + 'type' => 'datetimepicker', + 'name' => 'expiration_date', + 'label' => __( 'Expiration Date' ), + 'value' => $entry->expiration_date ?? '', + 'description' => __( 'Define what is the expiration date of the product.' ) + ], + ] + ] + ] + ]; + } + + /** + * Filter POST input fields + * @param array of fields + * @return array of fields + */ + public function filterPostInputs( $inputs ) + { + return $inputs; + } + + /** + * Filter PUT input fields + * @param array of fields + * @return array of fields + */ + public function filterPutInputs( $inputs, ProcurementProduct $entry ) + { + return $inputs; + } + + /** + * Before saving a record + * @param Request $request + * @return void + */ + public function beforePost( $request ) + { + if ( $this->permissions[ 'create' ] !== false ) { + ns()->restrict( $this->permissions[ 'create' ] ); + } else { + throw new NotAllowedException; + } + + return $request; + } + + /** + * After saving a record + * @param Request $request + * @param ProcurementProduct $entry + * @return void + */ + public function afterPost( $request, ProcurementProduct $entry ) + { + return $request; + } + + + /** + * get + * @param string + * @return mixed + */ + public function get( $param ) + { + switch( $param ) { + case 'model' : return $this->model ; break; + } + } + + /** + * Before updating a record + * @param Request $request + * @param object entry + * @return void + */ + public function beforePut( $request, $entry ) + { + if ( $this->permissions[ 'update' ] !== false ) { + ns()->restrict( $this->permissions[ 'update' ] ); + } else { + throw new NotAllowedException; + } + + return $request; + } + + /** + * After updating a record + * @param Request $request + * @param object entry + * @return void + */ + public function afterPut( $request, $entry ) + { + return $request; + } + + /** + * Before Delete + * @return void + */ + public function beforeDelete( $namespace, $id, $model ) { + if ( $namespace == 'ns.procurements-products' ) { + /** + * Perform an action before deleting an entry + * In case something wrong, this response can be returned + * + * return response([ + * 'status' => 'danger', + * 'message' => __( 'You\re not allowed to do that.' ) + * ], 403 ); + **/ + if ( $this->permissions[ 'delete' ] !== false ) { + ns()->restrict( $this->permissions[ 'delete' ] ); + } else { + throw new NotAllowedException; + } + } + } + + /** + * Define Columns + * @return array of columns configuration + */ + public function getColumns() { + return [ + 'name' => [ + 'label' => __( 'Name' ), + '$direction' => '', + '$sort' => false + ], + 'unit_name' => [ + 'label' => __( 'Unit' ), + '$direction' => '', + '$sort' => false + ], + 'procurement_name' => [ + 'label' => __( 'Procurement' ), + '$direction' => '', + '$sort' => false + ], + 'quantity' => [ + 'label' => __( 'Quantity' ), + '$direction' => '', + '$sort' => false + ], + 'total_purchase_price' => [ + 'label' => __( 'Total Price' ), + '$direction' => '', + '$sort' => false + ], + 'barcode' => [ + 'label' => __( 'Barcode' ), + '$direction' => '', + '$sort' => false + ], + 'expiration_date' => [ + 'label' => __( 'Expiration Date' ), + '$direction' => '', + '$sort' => false + ], + 'user_username' => [ + 'label' => __( 'Author' ), + '$direction' => '', + '$sort' => false + ], + 'created_at' => [ + 'label' => __( 'On' ), + '$direction' => '', + '$sort' => false + ], + ]; + } + + /** + * Define actions + */ + public function setActions( $entry, $namespace ) + { + // Don't overwrite + $entry->{ '$checked' } = false; + $entry->{ '$toggled' } = false; + $entry->{ '$id' } = $entry->id; + + foreach([ 'gross_purchase_price', 'net_purchase_price', 'total_purchase_price', 'purchase_price' ] as $label ) { + $entry->$label = ( string ) ns()->currency->define( $entry->$label ); + } + + // you can make changes here + $entry->{'$actions'} = [ + [ + 'label' => __( 'Edit' ), + 'namespace' => 'edit', + 'type' => 'GOTO', + 'url' => ns()->url( '/dashboard/' . $this->slug . '/edit/' . $entry->id ) + ], [ + 'label' => __( 'Delete' ), + 'namespace' => 'delete', + 'type' => 'DELETE', + 'url' => ns()->url( '/api/nexopos/v4/crud/ns.procurements-products/' . $entry->id ), + 'confirm' => [ + 'message' => __( 'Would you like to delete this ?' ), + ] + ] + ]; + + return $entry; + } + + + /** + * Bulk Delete Action + * @param object Request with object + * @return false/array + */ + public function bulkAction( Request $request ) + { + /** + * Deleting licence is only allowed for admin + * and supervisor. + */ + + if ( $request->input( 'action' ) == 'delete_selected' ) { + + /** + * Will control if the user has the permissoin to do that. + */ + if ( $this->permissions[ 'delete' ] !== false ) { + ns()->restrict( $this->permissions[ 'delete' ] ); + } else { + throw new NotAllowedException; + } + + $status = [ + 'success' => 0, + 'failed' => 0 + ]; + + foreach ( $request->input( 'entries' ) as $id ) { + $entity = $this->model::find( $id ); + if ( $entity instanceof ProcurementProduct ) { + $entity->delete(); + $status[ 'success' ]++; + } else { + $status[ 'failed' ]++; + } + } + return $status; + } + + return Hook::filter( $this->namespace . '-catch-action', false, $request ); + } + + /** + * get Links + * @return array of links + */ + public function getLinks() + { + return [ + 'list' => ns()->url( 'dashboard/' . 'procurements/products' ), + 'create' => 'javascript:void(0)', //ns()->url( 'dashboard/' . '/procurements/products/create' ), + 'edit' => ns()->url( 'dashboard/' . 'procurements/products/edit/' ), + 'post' => ns()->url( 'api/nexopos/v4/crud/' . 'ns.procurements-products' ), + 'put' => ns()->url( 'api/nexopos/v4/crud/' . 'ns.procurements-products/{id}' . '' ), + ]; + } + + /** + * Get Bulk actions + * @return array of actions + **/ + public function getBulkActions() + { + return Hook::filter( $this->namespace . '-bulk', [ + [ + 'label' => __( 'Delete Selected Groups' ), + 'identifier' => 'delete_selected', + 'url' => ns()->route( 'ns.api.crud-bulk-actions', [ + 'namespace' => $this->namespace + ]) + ] + ]); + } + + /** + * get exports + * @return array of export formats + **/ + public function getExports() + { + return []; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Dashboard/ProcurementController.php b/app/Http/Controllers/Dashboard/ProcurementController.php index b396fd633..7b3b21f7f 100755 --- a/app/Http/Controllers/Dashboard/ProcurementController.php +++ b/app/Http/Controllers/Dashboard/ProcurementController.php @@ -8,6 +8,7 @@ namespace App\Http\Controllers\Dashboard; use App\Crud\ProcurementCrud; +use App\Crud\ProcurementProductCrud; use App\Exceptions\NotAllowedException; use Illuminate\Http\Request; use Illuminate\Support\Facades\View; @@ -20,7 +21,9 @@ use App\Services\Options; use App\Http\Requests\ProcurementRequest; use App\Models\Procurement; +use App\Models\ProcurementProduct; use App\Models\Product; +use App\Models\Unit; use App\Services\ProductService; use Tendoo\Core\Exceptions\AccessDeniedException; @@ -236,7 +239,28 @@ public function procurementInvoice( Procurement $procurement ) public function searchProduct( Request $request ) { - $products = $this->productService->searchProduct( $request->input( 'search' ) ); + $products = Product::query()->orWhere( 'name', 'LIKE', "%{$request->input( 'argument' )}%" ) + ->searchable() + ->trackingDisabled() + ->with( 'unit_quantities.unit' ) + ->where( function( $query ) use ( $request ) { + $query->where( 'sku', 'LIKE', "%{$request->input( 'argument' )}%" ) + ->orWhere( 'barcode', 'LIKE', "%{$request->input( 'argument' )}%" ); + }) + ->limit( 5 ) + ->get() + ->map( function( $product ) { + $units = json_decode( $product->purchase_unit_ids ); + + if ( $units ) { + $product->purchase_units = collect(); + collect( $units )->each( function( $unitID ) use ( &$product ) { + $product->purchase_units->push( Unit::find( $unitID ) ); + }); + } + + return $product; + }); if ( ! $products->isEmpty() ) { return [ @@ -250,5 +274,15 @@ public function searchProduct( Request $request ) 'product' => $this->procurementService->searchProduct( $request->input( 'search' ) ) ]; } + + public function getProcurementProducts() + { + return ProcurementProductCrud::table(); + } + + public function editProcurementProduct( ProcurementProduct $product ) + { + return ProcurementProductCrud::form( $product ); + } } diff --git a/app/Http/Controllers/Dashboard/ProductsController.php b/app/Http/Controllers/Dashboard/ProductsController.php index a6bcc120d..b043bc59d 100755 --- a/app/Http/Controllers/Dashboard/ProductsController.php +++ b/app/Http/Controllers/Dashboard/ProductsController.php @@ -500,11 +500,12 @@ public function createAdjustment( Request $request ) */ foreach( $request->input( 'products' ) as $product ) { $results[] = $this->productService->stockAdjustment( $product[ 'adjust_action' ], [ - 'unit_price' => $product[ 'adjust_unit' ][ 'sale_price' ], - 'unit_id' => $product[ 'adjust_unit' ][ 'unit_id' ], - 'product_id' => $product[ 'id' ], - 'quantity' => $product[ 'adjust_quantity' ], - 'description' => $product[ 'adjust_reason' ] ?? '', + 'unit_price' => $product[ 'adjust_unit' ][ 'sale_price' ], + 'unit_id' => $product[ 'adjust_unit' ][ 'unit_id' ], + 'procurement_product_id' => $product[ 'procurement_product_id' ] ?? null, + 'product_id' => $product[ 'id' ], + 'quantity' => $product[ 'adjust_quantity' ], + 'description' => $product[ 'adjust_reason' ] ?? '', ]); } diff --git a/app/Models/ProcurementProduct.php b/app/Models/ProcurementProduct.php index 4366571f4..10e8dbcf3 100755 --- a/app/Models/ProcurementProduct.php +++ b/app/Models/ProcurementProduct.php @@ -17,6 +17,9 @@ class ProcurementProduct extends NsModel protected $table = 'nexopos_' . 'procurements_products'; + const STOCK_INCREASE = 'increase'; + const STOCK_REDUCE = 'reduce'; + protected $dispatchesEvents = [ 'creating' => ProcurementProductBeforeCreateEvent::class, 'created' => ProcurementProductAfterCreateEvent::class, diff --git a/app/Models/Product.php b/app/Models/Product.php index 2ac5227bd..9a78e293c 100755 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -17,6 +17,9 @@ class Product extends NsModel const EXPIRES_ALLOW_SALES = 'allow_sales'; protected $table = 'nexopos_' . 'products'; + protected $cats = [ + 'accurate_tracking' => 'boolean' + ]; public function category() { @@ -165,6 +168,17 @@ public function scopeWithStockDisabled( $query ) return $query->where( 'stock_management', Product::STOCK_MANAGEMENT_DISABLED ); } + /** + * Filter query by getitng product with + * accurate stock enabled or not. + * @param QueryBuilder $query + * @return QueryBuilder + */ + public function scopeAccurateTracking( $query, $argument = true ) + { + return $query->where( 'accurate_tracking', $argument ); + } + /** * Filter product that are searchable * @param QueryBuilder diff --git a/app/Providers/CrudServiceProvider.php b/app/Providers/CrudServiceProvider.php index 8c76f3e5b..43becbd0d 100755 --- a/app/Providers/CrudServiceProvider.php +++ b/app/Providers/CrudServiceProvider.php @@ -25,13 +25,13 @@ use App\Crud\TaxesGroupCrud; use App\Crud\UserCrud; use App\Crud\ProcurementCrud; +use App\Crud\ProcurementProductCrud; use App\Crud\ProductHistoryCrud; use App\Crud\ProductUnitQuantitiesCrud; use App\Crud\RegisterCrud; use App\Crud\RegisterHistoryCrud; use App\Crud\RolesCrud; use App\Crud\UnpaidOrderCrud; -use App\Models\ExpenseHistory; use Illuminate\Support\ServiceProvider; use TorMorten\Eventy\Facades\Events as Hook; @@ -88,6 +88,7 @@ public function boot() case 'ns.registers': return RegisterCrud::class; case 'ns.registers-hitory': return RegisterHistoryCrud::class; case 'ns.procurements': return ProcurementCrud::class; + case 'ns.procurements-products': return ProcurementProductCrud::class; case 'ns.roles': return RolesCrud::class; } return $namespace; diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index 706f28107..979072bda 100755 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -319,9 +319,14 @@ public function buildMenus() 'href' => ns()->url( '/dashboard/procurements' ) ], 'procurements-create' => [ - 'label' => __( 'New Procurement' ), + 'label' => __( 'New Procurement' ), 'permissions' => [ 'nexopos.create.procurements' ], - 'href' => ns()->url( '/dashboard/procurements/create' ) + 'href' => ns()->url( '/dashboard/procurements/create' ) + ], + 'procurements-products' => [ + 'label' => __( 'Products' ), + 'permissions' => [ 'nexopos.update.procurements' ], + 'href' => ns()->url( '/dashboard/procurements/products' ) ], ] ], diff --git a/app/Services/OrdersService.php b/app/Services/OrdersService.php index 29a511f93..7971a8a71 100755 --- a/app/Services/OrdersService.php +++ b/app/Services/OrdersService.php @@ -961,14 +961,7 @@ private function __saveOrderProducts($order, $products) $gross = 0; $products->each(function ($product) use (&$subTotal, &$taxes, &$order, &$gross) { - - /** - * this should run only if the product looped doesn't include an identifier. - * Usually if it's the case, the product is supposed to have been already handled before. - */ - // if ( empty( $product[ 'id' ] ) ) { - // } - + /** * storing the product * history as a sale diff --git a/app/Services/ProcurementService.php b/app/Services/ProcurementService.php index e4c00b6f8..61233347c 100755 --- a/app/Services/ProcurementService.php +++ b/app/Services/ProcurementService.php @@ -926,10 +926,12 @@ public function searchProduct( $argument ) ->with([ 'unit', 'procurement' ]) ->first(); - $procurementProduct->unit_quantity = $this->productService->getUnitQuantity( - $procurementProduct->product_id, - $procurementProduct->unit_id - ); + if ( $procurementProduct instanceof ProcurementProduct ) { + $procurementProduct->unit_quantity = $this->productService->getUnitQuantity( + $procurementProduct->product_id, + $procurementProduct->unit_id + ); + } return $procurementProduct; } diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index e554a0d4c..3f104713c 100755 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -935,14 +935,16 @@ public function stockAdjustment( $action, $data ) * @param id unit_id * @param float $unit_price * @param float total_price + * @param int procurement_product_id * @param string $description * @param float quantity * @param string sku * @param string $unit_identifier */ - $product = isset( $product_id ) ? Product::findOrFail( $product_id ) : Product::usingSKU( $sku )->first(); - $product_id = $product->id; - $unit_id = isset( $unit_id ) ? $unit_id : Unit::identifier( $unit_identifier )->firstOrFail()->id; + $product = isset( $product_id ) ? Product::findOrFail( $product_id ) : Product::usingSKU( $sku )->first(); + $product_id = $product->id; + $unit_id = isset( $unit_id ) ? $unit_id : Unit::identifier( $unit_identifier )->firstOrFail()->id; + $procurementProduct = isset( $procurement_product_id ) ? ProcurementProduct::find( $procurement_product_id ) : false; /** * let's check the different @@ -1011,6 +1013,15 @@ public function stockAdjustment( $action, $data ) * @var array [ 'oldQuantity', 'newQuantity' ] */ $result = $this->reduceUnitQuantities( $product_id, $unit_id, abs( $quantity ), $oldQuantity ); + + /** + * We should reduce the quantity if + * we're dealing with a product that has + * accurate stock tracking + */ + if ( $procurementProduct instanceof ProcurementProduct ) { + $this->updateProcurementProductQuantity( $procurementProduct, $quantity, ProcurementProduct::STOCK_REDUCE ); + } } else { /** @@ -1019,6 +1030,15 @@ public function stockAdjustment( $action, $data ) * @var array [ 'oldQuantity', 'newQuantity' ] */ $result = $this->increaseUnitQuantities( $product_id, $unit_id, abs( $quantity ), $oldQuantity ); + + /** + * We should reduce the quantity if + * we're dealing with a product that has + * accurate stock tracking + */ + if ( $procurementProduct instanceof ProcurementProduct ) { + $this->updateProcurementProductQuantity( $procurementProduct, $quantity, ProcurementProduct::STOCK_INCREASE ); + } } } @@ -1043,6 +1063,23 @@ public function stockAdjustment( $action, $data ) return $history; } + /** + * Update procurement product quantity + * @param ProcurementProduct $procurementProduct + * @param int $quantity + * @param string $action + */ + public function updateProcurementProductQuantity( $procurementProduct, $quantity, $action ) + { + if ( $action === ProcurementProduct::STOCK_INCREASE ) { + $procurementProduct->available_quantity += $quantity; + } else if ( $action === ProcurementProduct::STOCK_REDUCE ) { + $procurementProduct->available_quantity -= $quantity; + } + + $procurementProduct->save(); + } + /** * reduce Product unit quantities and update * the available quantity for the unit provided diff --git a/resources/ts/components/ns-date-time-picker.ts b/resources/ts/components/ns-date-time-picker.ts index 38dd2db27..edd244044 100644 --- a/resources/ts/components/ns-date-time-picker.ts +++ b/resources/ts/components/ns-date-time-picker.ts @@ -12,7 +12,7 @@ const nsDateTimePicker = Vue.component( 'ns-date-time-picker', { N/A -

{{ field.descrition }}

+

{{ field.description }}