diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index aa23c649e5e2..343099837e6e 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -14,99 +14,119 @@ namespace App\Http\Controllers; use App\Http\Requests\Import\ImportRequest; use App\Http\Requests\Import\PreImportRequest; use App\Jobs\Import\CSVImport; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use League\Csv\Reader; use League\Csv\Statement; -class ImportController extends Controller -{ +class ImportController extends Controller { - /** - * Store a newly created resource in storage. - * - * @param StoreImportRequest $request - * @return Response - * - * @OA\Post( - * path="/api/v1/preimport", - * operationId="preimport", - * tags={"imports"}, - * summary="Pre Import checks - returns a reference to the job and the headers of the CSV", - * description="Pre Import checks - returns a reference to the job and the headers of the CSV", - * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), - * @OA\Parameter(ref="#/components/parameters/X-Api-Token"), - * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), - * @OA\Parameter(ref="#/components/parameters/include"), - * @OA\RequestBody( - * description="The CSV file", - * required=true, - * @OA\MediaType( - * mediaType="multipart/form-data", - * @OA\Schema( - * type="string", - * format="binary" - * ) - * ) - * ), - * @OA\Response( - * response=200, - * description="Returns a reference to the file", - * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), - * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), - * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), - * ), - * @OA\Response( - * response=422, - * description="Validation error", - * @OA\JsonContent(ref="#/components/schemas/ValidationError"), - * - * ), - * @OA\Response( - * response="default", - * description="Unexpected Error", - * @OA\JsonContent(ref="#/components/schemas/Error"), - * ), - * ) - */ - public function preimport(PreImportRequest $request) - { - //create a reference - $hash = Str::random(32); + /** + * Store a newly created resource in storage. + * + * @param PreImportRequest $request + * + * @return \Illuminate\Http\JsonResponse + * + * @OA\Post( + * path="/api/v1/preimport", + * operationId="preimport", + * tags={"imports"}, + * summary="Pre Import checks - returns a reference to the job and the headers of the CSV", + * description="Pre Import checks - returns a reference to the job and the headers of the CSV", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Api-Token"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Parameter(ref="#/components/parameters/include"), + * @OA\RequestBody( + * description="The CSV file", + * required=true, + * @OA\MediaType( + * mediaType="multipart/form-data", + * @OA\Schema( + * type="string", + * format="binary" + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Returns a reference to the file", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + public function preimport( PreImportRequest $request ) { + // Create a reference + $hash = Str::random( 32 ); - //store the csv in cache with an expiry of 10 minutes - Cache::put($hash, base64_encode(file_get_contents($request->file('file')->getPathname())), 3600); + $data = [ + 'hash' => $hash, + 'mappings' => [], + ]; + /** @var UploadedFile $file */ + foreach ( $request->files->get( 'files' ) as $entityType => $file ) { + $contents = file_get_contents( $file->getPathname() ); - //parse CSV - $csv_array = $this->getCsvData(file_get_contents($request->file('file')->getPathname())); + // Store the csv in cache with an expiry of 10 minutes + Cache::put( $hash . '-' . $entityType, base64_encode( $contents ), 3600 ); - $class_map = $this->getEntityMap($request->input('entity_type')); + // Parse CSV + $csv_array = $this->getCsvData( $contents ); - $data = [ - 'hash' => $hash, - 'available' => $class_map::importable(), - 'headers' => array_slice($csv_array, 0, 2) - ]; + $class_map = $this->getEntityMap( $entityType ); - return response()->json($data); - } + $data['mappings'][ $entityType ] = [ + 'available' => $class_map::importable(), + 'headers' => array_slice( $csv_array, 0, 2 ), + ]; + } - public function import(ImportRequest $request) - { - CSVImport::dispatch($request->all(), auth()->user()->company()); - - return response()->json(['message' => ctrans('texts.import_started')], 200); - } + return response()->json( $data ); + } - private function getEntityMap($entity_type) - { - return sprintf('App\\Import\\Definitions\%sMap', ucfirst($entity_type)); - } + public function import( ImportRequest $request ) { + $data = $request->all(); - private function getCsvData($csvfile) - { - if (! ini_get('auto_detect_line_endings')) { - ini_set('auto_detect_line_endings', '1'); + if ( empty( $data['hash'] ) ) { + // Create a reference + $data['hash'] = $hash = Str::random( 32 ); + + /** @var UploadedFile $file */ + foreach ( $request->files->get( 'files' ) as $entityType => $file ) { + $contents = file_get_contents( $file->getPathname() ); + + // Store the csv in cache with an expiry of 10 minutes + Cache::put( $hash . '-' . $entityType, base64_encode( $contents ), 3600 ); + } + } + + CSVImport::dispatchNow( $data, auth()->user()->company() ); + + return response()->json( [ 'message' => ctrans( 'texts.import_started' ) ], 200 ); + } + + private function getEntityMap( $entity_type ) { + return sprintf( 'App\\Import\\Definitions\%sMap', ucfirst( $entity_type ) ); + } + + private function getCsvData( $csvfile ) { + if ( ! ini_get( 'auto_detect_line_endings' ) ) { + ini_set( 'auto_detect_line_endings', '1' ); } $csv = Reader::createFromString($csvfile); @@ -121,10 +141,10 @@ class ImportController extends Controller $firstCell = $headers[0]; if (strstr($firstCell, (string)config('ninja.app_name'))) { - array_shift($data); // Invoice Ninja... - array_shift($data); // - array_shift($data); // Enitty Type Header - } + array_shift( $data ); // Invoice Ninja... + array_shift( $data ); // + array_shift( $data ); // Entity Type Header + } } } diff --git a/app/Http/Requests/Import/ImportRequest.php b/app/Http/Requests/Import/ImportRequest.php index a8564c4abf64..dc1074653edf 100644 --- a/app/Http/Requests/Import/ImportRequest.php +++ b/app/Http/Requests/Import/ImportRequest.php @@ -28,10 +28,12 @@ class ImportRequest extends Request public function rules() { return [ + 'import_type' => 'required', + 'files' => 'required_without:hash|array|min:1|max:6', 'hash' => 'required|string', - 'entity_type' => 'required|string', - 'column_map' => 'required|array', - 'skip_header' => 'required|boolean' + 'column_map' => 'required_with:hash|array', + 'skip_header' => 'required_with:hash|boolean', + 'files.*' => 'file|mimes:csv,txt', ]; } } diff --git a/app/Http/Requests/Import/PreImportRequest.php b/app/Http/Requests/Import/PreImportRequest.php index 75013dfe2924..cdeebc0d6a32 100644 --- a/app/Http/Requests/Import/PreImportRequest.php +++ b/app/Http/Requests/Import/PreImportRequest.php @@ -28,8 +28,9 @@ class PreImportRequest extends Request public function rules() { return [ - 'file' => 'required|file|mimes:csv,txt', - 'entity_type' => 'required', + 'files.*' => 'file|mimes:csv,txt', + 'files' => 'required|array|min:1|max:6', + 'import_type' => 'required', ]; } } diff --git a/app/Import/Definitions/InvoiceMap.php b/app/Import/Definitions/InvoiceMap.php index 9439e36e3150..299c0dd84d30 100644 --- a/app/Import/Definitions/InvoiceMap.php +++ b/app/Import/Definitions/InvoiceMap.php @@ -26,50 +26,51 @@ class InvoiceMap 7 => 'invoice.date', 8 => 'invoice.due_date', 9 => 'invoice.terms', - 10 => 'invoice.public_notes', - 11 => 'invoice.is_sent', - 12 => 'invoice.private_notes', - 13 => 'invoice.uses_inclusive_taxes', - 14 => 'invoice.tax_name1', - 15 => 'invoice.tax_rate1', - 16 => 'invoice.tax_name2', - 17 => 'invoice.tax_rate2', - 18 => 'invoice.tax_name3', - 19 => 'invoice.tax_rate3', - 20 => 'invoice.is_amount_discount', - 21 => 'invoice.footer', - 22 => 'invoice.partial', - 23 => 'invoice.partial_due_date', - 24 => 'invoice.custom_value1', - 25 => 'invoice.custom_value2', - 26 => 'invoice.custom_value3', - 27 => 'invoice.custom_value4', - 28 => 'invoice.custom_surcharge1', - 29 => 'invoice.custom_surcharge2', - 30 => 'invoice.custom_surcharge3', - 31 => 'invoice.custom_surcharge4', - 32 => 'invoice.exchange_rate', - 33 => 'payment.date', - 34 => 'payment.amount', - 35 => 'payment.transaction_reference', - 36 => 'item.quantity', - 37 => 'item.cost', - 38 => 'item.product_key', - 39 => 'item.notes', - 40 => 'item.discount', - 41 => 'item.is_amount_discount', - 42 => 'item.tax_name1', - 43 => 'item.tax_rate1', - 44 => 'item.tax_name2', - 45 => 'item.tax_rate2', - 46 => 'item.tax_name3', - 47 => 'item.tax_rate3', - 48 => 'item.custom_value1', - 49 => 'item.custom_value2', - 50 => 'item.custom_value3', - 51 => 'item.custom_value4', - 52 => 'item.type_id', - 53 => 'client.email', + 10 => 'invoice.status', + 11 => 'invoice.public_notes', + 12 => 'invoice.is_sent', + 13 => 'invoice.private_notes', + 14 => 'invoice.uses_inclusive_taxes', + 15 => 'invoice.tax_name1', + 16 => 'invoice.tax_rate1', + 17 => 'invoice.tax_name2', + 18 => 'invoice.tax_rate2', + 19 => 'invoice.tax_name3', + 20 => 'invoice.tax_rate3', + 21 => 'invoice.is_amount_discount', + 22 => 'invoice.footer', + 23 => 'invoice.partial', + 24 => 'invoice.partial_due_date', + 25 => 'invoice.custom_value1', + 26 => 'invoice.custom_value2', + 27 => 'invoice.custom_value3', + 28 => 'invoice.custom_value4', + 29 => 'invoice.custom_surcharge1', + 30 => 'invoice.custom_surcharge2', + 31 => 'invoice.custom_surcharge3', + 32 => 'invoice.custom_surcharge4', + 33 => 'invoice.exchange_rate', + 34 => 'payment.date', + 35 => 'payment.amount', + 36 => 'payment.transaction_reference', + 37 => 'item.quantity', + 38 => 'item.cost', + 39 => 'item.product_key', + 40 => 'item.notes', + 41 => 'item.discount', + 42 => 'item.is_amount_discount', + 43 => 'item.tax_name1', + 44 => 'item.tax_rate1', + 45 => 'item.tax_name2', + 46 => 'item.tax_rate2', + 47 => 'item.tax_name3', + 48 => 'item.tax_rate3', + 49 => 'item.custom_value1', + 50 => 'item.custom_value2', + 51 => 'item.custom_value3', + 52 => 'item.custom_value4', + 53 => 'item.type_id', + 54 => 'client.email', ]; } @@ -86,50 +87,51 @@ class InvoiceMap 7 => 'texts.date', 8 => 'texts.due_date', 9 => 'texts.terms', - 10 => 'texts.public_notes', - 11 => 'texts.sent', - 12 => 'texts.private_notes', - 13 => 'texts.uses_inclusive_taxes', - 14 => 'texts.tax_name', - 15 => 'texts.tax_rate', - 16 => 'texts.tax_name', - 17 => 'texts.tax_rate', - 18 => 'texts.tax_name', - 19 => 'texts.tax_rate', - 20 => 'texts.is_amount_discount', - 21 => 'texts.footer', - 22 => 'texts.partial', - 23 => 'texts.partial_due_date', - 24 => 'texts.custom_value1', - 25 => 'texts.custom_value2', - 26 => 'texts.custom_value3', - 27 => 'texts.custom_value4', - 28 => 'texts.surcharge', + 10 => 'texts.status', + 11 => 'texts.public_notes', + 12 => 'texts.sent', + 13 => 'texts.private_notes', + 14 => 'texts.uses_inclusive_taxes', + 15 => 'texts.tax_name', + 16 => 'texts.tax_rate', + 17 => 'texts.tax_name', + 18 => 'texts.tax_rate', + 19 => 'texts.tax_name', + 20 => 'texts.tax_rate', + 21 => 'texts.is_amount_discount', + 22 => 'texts.footer', + 23 => 'texts.partial', + 24 => 'texts.partial_due_date', + 25 => 'texts.custom_value1', + 26 => 'texts.custom_value2', + 27 => 'texts.custom_value3', + 28 => 'texts.custom_value4', 29 => 'texts.surcharge', 30 => 'texts.surcharge', 31 => 'texts.surcharge', - 32 => 'texts.exchange_rate', - 33 => 'texts.payment_date', - 34 => 'texts.payment_amount', - 35 => 'texts.transaction_reference', - 36 => 'texts.quantity', - 37 => 'texts.cost', - 38 => 'texts.product_key', - 39 => 'texts.notes', - 40 => 'texts.discount', - 41 => 'texts.is_amount_discount', - 42 => 'texts.tax_name', - 43 => 'texts.tax_rate', - 44 => 'texts.tax_name', - 45 => 'texts.tax_rate', - 46 => 'texts.tax_name', - 47 => 'texts.tax_rate', - 48 => 'texts.custom_value', + 32 => 'texts.surcharge', + 33 => 'texts.exchange_rate', + 34 => 'texts.payment_date', + 35 => 'texts.payment_amount', + 36 => 'texts.transaction_reference', + 37 => 'texts.quantity', + 38 => 'texts.cost', + 39 => 'texts.product_key', + 40 => 'texts.notes', + 41 => 'texts.discount', + 42 => 'texts.is_amount_discount', + 43 => 'texts.tax_name', + 44 => 'texts.tax_rate', + 45 => 'texts.tax_name', + 46 => 'texts.tax_rate', + 47 => 'texts.tax_name', + 48 => 'texts.tax_rate', 49 => 'texts.custom_value', 50 => 'texts.custom_value', 51 => 'texts.custom_value', - 52 => 'texts.type', - 53 => 'texts.email', + 52 => 'texts.custom_value', + 53 => 'texts.type', + 54 => 'texts.email', ]; } } diff --git a/app/Import/Transformers/ClientTransformer.php b/app/Import/Transformers/ClientTransformer.php index c12ed0ef840a..9f1c4f8806c9 100644 --- a/app/Import/Transformers/ClientTransformer.php +++ b/app/Import/Transformers/ClientTransformer.php @@ -19,10 +19,10 @@ use Illuminate\Support\Str; class ClientTransformer extends BaseTransformer { /** - * @param $data - * - * @return bool|Item - */ + * @param $data + * + * @return array + */ public function transform($data) { if (isset($data->name) && $this->hasClient($data->name)) { @@ -33,46 +33,46 @@ class ClientTransformer extends BaseTransformer $settings->currency_id = (string)$this->getCurrencyByCode($data); return [ - 'company_id' => $this->maps['company']->id, - 'name' => $this->getString($data, 'client.name'), - 'work_phone' => $this->getString($data, 'client.phone'), - 'address1' => $this->getString($data, 'client.address1'), - 'address2' => $this->getString($data, 'client.address2'), - 'city' => $this->getString($data, 'client.city'), - 'state' => $this->getString($data, 'client.state'), - 'shipping_address1' => $this->getString($data, 'client.shipping_address1'), - 'shipping_address2' => $this->getString($data, 'client.shipping_address2'), - 'shipping_city' => $this->getString($data, 'client.shipping_city'), - 'shipping_state' => $this->getString($data, 'client.shipping_state'), - 'shipping_postal_code' => $this->getString($data, 'client.shipping_postal_code'), - 'public_notes' => $this->getString($data, 'client.public_notes'), - 'private_notes' => $this->getString($data, 'client.private_notes'), - 'website' => $this->getString($data, 'client.website'), - 'vat_number' => $this->getString($data, 'client.vat_number'), - 'id_number' => $this->getString($data, 'client.id_number'), - 'custom_value1' => $this->getString($data, 'client.custom1'), - 'custom_value2' => $this->getString($data, 'client.custom2'), - 'custom_value3' => $this->getString($data, 'client.custom3'), - 'custom_value4' => $this->getString($data, 'client.custom4'), - 'balance' => $this->getFloat($data, 'client.balance'), - 'paid_to_date' => $this->getFloat($data, 'client.paid_to_date'), - 'credit_balance' => 0, - 'settings' => $settings, - 'client_hash' => Str::random(40), - 'contacts' => [ - [ - 'first_name' => $this->getString($data, 'contact.first_name'), - 'last_name' => $this->getString($data, 'contact.last_name'), - 'email' => $this->getString($data, 'contact.email'), - 'phone' => $this->getString($data, 'contact.phone'), - 'custom_value1' => $this->getString($data, 'contact.custom1'), - 'custom_value2' => $this->getString($data, 'contact.custom2'), - 'custom_value3' => $this->getString($data, 'contact.custom3'), - 'custom_value4' => $this->getString($data, 'contact.custom4'), - ], - ], - 'country_id' => isset($data->country_id) ? $this->getCountryId($data->country_id) : null, - 'shipping_country_id' => isset($data->shipping_country_id) ? $this->getCountryId($data->shipping_country_id) : null, - ]; + 'company_id' => $this->maps['company']->id, + 'name' => $this->getString( $data, 'client.name' ), + 'work_phone' => $this->getString( $data, 'client.phone' ), + 'address1' => $this->getString( $data, 'client.address1' ), + 'address2' => $this->getString( $data, 'client.address2' ), + 'city' => $this->getString( $data, 'client.city' ), + 'state' => $this->getString( $data, 'client.state' ), + 'shipping_address1' => $this->getString( $data, 'client.shipping_address1' ), + 'shipping_address2' => $this->getString( $data, 'client.shipping_address2' ), + 'shipping_city' => $this->getString( $data, 'client.shipping_city' ), + 'shipping_state' => $this->getString( $data, 'client.shipping_state' ), + 'shipping_postal_code' => $this->getString( $data, 'client.shipping_postal_code' ), + 'public_notes' => $this->getString( $data, 'client.public_notes' ), + 'private_notes' => $this->getString( $data, 'client.private_notes' ), + 'website' => $this->getString( $data, 'client.website' ), + 'vat_number' => $this->getString( $data, 'client.vat_number' ), + 'id_number' => $this->getString( $data, 'client.id_number' ), + 'custom_value1' => $this->getString( $data, 'client.custom1' ), + 'custom_value2' => $this->getString( $data, 'client.custom2' ), + 'custom_value3' => $this->getString( $data, 'client.custom3' ), + 'custom_value4' => $this->getString( $data, 'client.custom4' ), + 'balance' => preg_replace( '/[^0-9,.]+/', '', $this->getFloat( $data, 'client.balance' ) ), + 'paid_to_date' => preg_replace( '/[^0-9,.]+/', '', $this->getFloat( $data, 'client.paid_to_date' ) ), + 'credit_balance' => 0, + 'settings' => $settings, + 'client_hash' => Str::random( 40 ), + 'contacts' => [ + [ + 'first_name' => $this->getString( $data, 'contact.first_name' ), + 'last_name' => $this->getString( $data, 'contact.last_name' ), + 'email' => $this->getString( $data, 'contact.email' ), + 'phone' => $this->getString( $data, 'contact.phone' ), + 'custom_value1' => $this->getString( $data, 'contact.custom1' ), + 'custom_value2' => $this->getString( $data, 'contact.custom2' ), + 'custom_value3' => $this->getString( $data, 'contact.custom3' ), + 'custom_value4' => $this->getString( $data, 'contact.custom4' ), + ], + ], + 'country_id' => isset( $data->country_id ) ? $this->getCountryId( $data->country_id ) : null, + 'shipping_country_id' => isset( $data->shipping_country_id ) ? $this->getCountryId( $data->shipping_country_id ) : null, + ]; } } diff --git a/app/Import/Transformers/InvoiceTransformer.php b/app/Import/Transformers/InvoiceTransformer.php index 022388b99d7e..fb1603e3c41c 100644 --- a/app/Import/Transformers/InvoiceTransformer.php +++ b/app/Import/Transformers/InvoiceTransformer.php @@ -19,7 +19,7 @@ class InvoiceTransformer extends BaseTransformer /** * @param $data * - * @return bool|Item + * @return array */ public function transform($data) { @@ -27,8 +27,8 @@ class InvoiceTransformer extends BaseTransformer 'company_id' => $this->maps['company']->id, 'number' => $this->getString($data, 'invoice.number'), 'user_id' => $this->getString($data, 'invoice.user_id'), - 'amount' => $this->getFloat($data, 'invoice.amount'), - 'balance' => $this->getFloat($data, 'invoice.balance'), + 'amount' => $amount = $this->getFloat($data, 'invoice.amount'), + 'balance' => isset( $data['invoice.balance'] ) ? $this->getFloat( $data, 'invoice.balance' ) : $amount, 'client_id' => $this->getClient($this->getString($data, 'client.name'), $this->getString($data, 'client.email')), 'discount' => $this->getFloat($data, 'invoice.discount'), 'po_number' => $this->getString($data, 'invoice.po_number'), diff --git a/app/Jobs/Import/CSVImport.php b/app/Jobs/Import/CSVImport.php index d333bde1653e..51a631975bbf 100644 --- a/app/Jobs/Import/CSVImport.php +++ b/app/Jobs/Import/CSVImport.php @@ -13,26 +13,35 @@ namespace App\Jobs\Import; use App\Factory\ClientFactory; use App\Factory\InvoiceFactory; +use App\Factory\PaymentFactory; use App\Factory\ProductFactory; use App\Http\Requests\Client\StoreClientRequest; use App\Http\Requests\Invoice\StoreInvoiceRequest; use App\Http\Requests\Product\StoreProductRequest; +use App\Import\Transformers\BaseTransformer; use App\Import\Transformers\ClientTransformer; use App\Import\Transformers\InvoiceItemTransformer; use App\Import\Transformers\InvoiceTransformer; +use App\Import\Transformers\PaymentTransformer; use App\Import\Transformers\ProductTransformer; use App\Jobs\Mail\MailRouter; use App\Libraries\MultiDB; use App\Mail\Import\ImportCompleted; use App\Models\Client; +use App\Models\ClientContact; use App\Models\Company; +use App\Models\Country; use App\Models\Currency; +use App\Models\Expense; +use App\Models\ExpenseCategory; use App\Models\Invoice; +use App\Models\Payment; +use App\Models\Product; +use App\Models\TaxRate; use App\Models\User; -use App\Repositories\ClientContactRepository; -use App\Repositories\ClientRepository; +use App\Models\Vendor; use App\Repositories\InvoiceRepository; -use App\Repositories\ProductRepository; +use App\Repositories\PaymentRepository; use App\Utils\Traits\CleanLineItems; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -42,328 +51,427 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use League\Csv\Reader; use League\Csv\Statement; -class CSVImport implements ShouldQueue -{ - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, CleanLineItems; +class CSVImport implements ShouldQueue { + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, CleanLineItems; - public $invoice; + public $invoice; - public $company; + public $company; - public $hash; + public $hash; - public $entity_type; + public $import_type; - public $skip_header; + public $skip_header; - public $column_map; + public $column_map; - public $import_array; + public $import_array; - public $error_array; + public $error_array = []; - public $maps; + public $maps; - public function __construct(array $request, Company $company) - { - $this->company = $company; + public function __construct( array $request, Company $company ) { + $this->company = $company; - $this->hash = $request['hash']; + $this->hash = $request['hash']; - $this->entity_type = $request['entity_type']; + $this->import_type = $request['import_type']; - $this->skip_header = $request['skip_header']; + $this->skip_header = $request['skip_header'] ?? null; - $this->column_map = $request['column_map']; - } + $this->column_map = $request['column_map'] ?? null; + } - /** - * Execute the job. - * - * - * @return void - */ - public function handle() - { - MultiDB::setDb($this->company->db); + /** + * Execute the job. + * + * + * @return void + */ + public function handle() { - $this->company->owner()->setCompany($this->company); - Auth::login($this->company->owner(), true); + MultiDB::setDb( $this->company->db ); - $this->buildMaps(); + $this->company->owner()->setCompany( $this->company ); + Auth::login( $this->company->owner(), true ); - //sort the array by key - ksort($this->column_map); + $this->buildMaps(); - nlog("import".ucfirst($this->entity_type)); - $this->{"import".ucfirst($this->entity_type)}(); - - $data = [ - 'entity' => ucfirst($this->entity_type), - 'errors' => $this->error_array, - 'clients' => $this->maps['clients'], - 'products' => $this->maps['products'], - 'invoices' => $this->maps['invoices'], - 'settings' => $this->company->settings - ]; + //sort the array by key + foreach ( $this->column_map as $entityType => &$map ) { + ksort( $map ); + } - //nlog(print_r($data, 1)); + nlog( "import" . ucfirst( $this->import_type ) ); + $this->{"import" . ucfirst( $this->import_type )}(); - MailRouter::dispatch(new ImportCompleted($data), $this->company, auth()->user()); - } + $data = [ + 'errors' => $this->error_array, + 'company'=>$this->company, + ]; - public function failed($exception) - { - } - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - + MailRouter::dispatchNow( new ImportCompleted( $data ), $this->company, auth()->user() ); + } + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - private function importInvoice() - { - $invoice_transformer = new InvoiceTransformer($this->maps); + private function importCsv() { + foreach ( [ 'client', 'product', 'invoice', 'payment', 'vendor', 'expense' ] as $entityType ) { + if ( empty( $this->column_map[ $entityType ] ) ) { + continue; + } - $records = $this->getCsvData(); + $csvData = $this->getCsvData( $entityType ); - $invoice_number_key = array_search('Invoice Number', reset($records)); + if ( ! empty( $csvData ) ) { + $importFunction = "import" . Str::plural( Str::title( $entityType ) ); - if ($this->skip_header) { - array_shift($records); - } + if ( method_exists( $this, $importFunction ) ) { + // If there's an entity-specific import function, use that. + $this->$importFunction( $csvData ); + } else { + // Otherwise, use the generic import function. + $this->importEntities( $csvData, $entityType ); + } + } + } + } - if (!$invoice_number_key) { - nlog("no invoice number to use as key - returning"); - return; - } + private function importInvoices( $records ) { + $invoice_transformer = new InvoiceTransformer( $this->maps ); - $unique_invoices = []; + if ( $this->skip_header ) { + array_shift( $records ); + } - //get an array of unique invoice numbers - foreach ($records as $key => $value) { - $unique_invoices[] = $value[$invoice_number_key]; - } + $keys = $this->column_map['invoice']; + $invoice_number_key = array_search( 'invoice.number', $keys ); + if ( $invoice_number_key === false ) { + nlog( "no invoice number to use as key - returning" ); - foreach ($unique_invoices as $unique) { - $invoices = array_filter($records, function ($value) use ($invoice_number_key, $unique) { - return $value[$invoice_number_key] == $unique; - }); + return; + } - $keys = $this->column_map; - $values = array_intersect_key(reset($invoices), $this->column_map); - $invoice_data = array_combine($keys, $values); + $items_by_invoice = []; - $invoice = $invoice_transformer->transform($invoice_data); - - $this->processInvoice($invoices, $invoice); - } - } - - private function processInvoice($invoices, $invoice) - { - $invoice_repository = new InvoiceRepository(); - $item_transformer = new InvoiceItemTransformer($this->maps); - $items = []; - - foreach ($invoices as $record) { - $keys = $this->column_map; - $values = array_intersect_key($record, $this->column_map); - $invoice_data = array_combine($keys, $values); - - $items[] = $item_transformer->transform($invoice_data); - } - - $invoice['line_items'] = $this->cleanItems($items); - - $validator = Validator::make($invoice, (new StoreInvoiceRequest())->rules()); - - if ($validator->fails()) { - $this->error_array['invoices'] = ['invoice' => $invoice, 'error' => json_encode($validator->errors())]; - } else { - if ($validator->fails()) { - $this->error_array[] = ['invoice' => $invoice, 'error' => json_encode($validator->errors())]; - } else { - $invoice = $invoice_repository->save($invoice, InvoiceFactory::create($this->company->id, $this->setUser($record))); - - $this->maps['invoices'][] = $invoice->id; - - $this->performInvoiceActions($invoice, $record, $invoice_repository); - } - } - } - - private function performInvoiceActions($invoice, $record, $invoice_repository) - { - $invoice = $this->actionInvoiceStatus($invoice, $record, $invoice_repository); - } - - private function actionInvoiceStatus($invoice, $status, $invoice_repository) - { - switch ($status) { - case 'Archived': - $invoice_repository->archive($invoice); - $invoice->fresh(); - break; - case 'Sent': - $invoice = $invoice->service()->markSent()->save(); - break; - case 'Viewed': - $invoice = $invoice->service()->markSent()->save(); - break; - default: - # code... - break; - } - - if ($invoice->balance < $invoice->amount && $invoice->status_id <= Invoice::STATUS_SENT) { - $invoice->status_id = Invoice::STATUS_PARTIAL; - $invoice->save(); - } - - return $invoice; - } - - //todo limit client imports for hosted version - private function importClient() - { - //clients - $records = $this->getCsvData(); - - $contact_repository = new ClientContactRepository(); - $client_repository = new ClientRepository($contact_repository); - $client_transformer = new ClientTransformer($this->maps); - - if ($this->skip_header) { - array_shift($records); - } - - foreach ($records as $record) { - $keys = $this->column_map; - $values = array_intersect_key($record, $this->column_map); - - $client_data = array_combine($keys, $values); - - $client = $client_transformer->transform($client_data); - - $validator = Validator::make($client, (new StoreClientRequest())->rules()); - - if ($validator->fails()) { - $this->error_array['clients'] = ['client' => $client, 'error' => json_encode($validator->errors())]; - } else { - $client = $client_repository->save($client, ClientFactory::create($this->company->id, $this->setUser($record))); - - if (array_key_exists('client.balance', $client_data)) { - $client->balance = preg_replace('/[^0-9,.]+/', '', $client_data['client.balance']); - } - - if (array_key_exists('client.paid_to_date', $client_data)) { - $client->paid_to_date = preg_replace('/[^0-9,.]+/', '', $client_data['client.paid_to_date']); - } - - $client->save(); - - $this->maps['clients'][] = $client->id; - } - } - } - - - private function importProduct() - { - $product_repository = new ProductRepository(); - $product_transformer = new ProductTransformer($this->maps); - - $records = $this->getCsvData(); - - if ($this->skip_header) { - array_shift($records); - } - - foreach ($records as $record) { - $keys = $this->column_map; - $values = array_intersect_key($record, $this->column_map); - - $product_data = array_combine($keys, $values); - - $product = $product_transformer->transform($product_data); - - $validator = Validator::make($product, (new StoreProductRequest())->rules()); - - if ($validator->fails()) { - $this->error_array['products'] = ['product' => $product, 'error' => json_encode($validator->errors())]; - } else { - $product = $product_repository->save($product, ProductFactory::create($this->company->id, $this->setUser($record))); - - $product->save(); - - $this->maps['products'][] = $product->id; - } - } - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - private function buildMaps() - { - $this->maps['currencies'] = Currency::all(); - $this->maps['users'] = $this->company->users; - $this->maps['company'] = $this->company; - $this->maps['clients'] = []; - $this->maps['products'] = []; - $this->maps['invoices'] = []; - - return $this; - } - - - private function setUser($record) - { - $user_key_exists = array_search('client.user_id', $this->column_map); - - if ($user_key_exists) { - return $this->findUser($record[$user_key_exists]); - } else { - return $this->company->owner()->id; - } - } - - private function findUser($user_hash) - { - $user = User::where('company_id', $this->company->id) - ->where(\DB::raw('CONCAT_WS(" ", first_name, last_name)'), 'like', '%' . $user_hash . '%') - ->first(); - - if ($user) { - return $user->id; - } else { - return $this->company->owner()->id; - } - } - - private function getCsvData() - { - $base64_encoded_csv = Cache::get($this->hash); - $csv = base64_decode($base64_encoded_csv); - $csv = Reader::createFromString($csv); - - $stmt = new Statement(); - $data = iterator_to_array($stmt->process($csv)); - - if (count($data) > 0) { - $headers = $data[0]; - - // Remove Invoice Ninja headers - if (count($headers) && count($data) > 4) { - $firstCell = $headers[0]; - if (strstr($firstCell, config('ninja.app_name'))) { - array_shift($data); // Invoice Ninja... - array_shift($data); // - array_shift($data); // Enitty Type Header - } - } - } + // Group line items by invoice and map columns to keys. + foreach ( $records as $key => $value ) { + $items_by_invoice[ $value[ $invoice_number_key ] ][] = array_combine( $keys,array_intersect_key( $value , $keys )); + } + + foreach ( $items_by_invoice as $invoice_number => $line_items ) { + $invoice_data = array_combine( $keys, reset( $line_items ) ); + + $invoice = $invoice_transformer->transform( $invoice_data ); + + $this->processInvoice( $line_items, $invoice ); + } + } + + private function processInvoice( $line_items, $invoice ) { + $invoice_repository = new InvoiceRepository(); + $item_transformer = new InvoiceItemTransformer( $this->maps ); + $items = []; + + foreach ( $line_items as $record ) { + $items[] = $item_transformer->transform( $record ); + } + + $invoice['line_items'] = $this->cleanItems( $items ); + + $validator = Validator::make( $invoice, ( new StoreInvoiceRequest() )->rules() ); + + if ( $validator->fails() ) { + $this->error_array['invoice'][] = [ 'invoice' => $invoice, 'error' => $validator->errors()->all() ]; + } else { + $invoice = + $invoice_repository->save( $invoice, InvoiceFactory::create( $this->company->id, $this->getUserIDForRecord( $record ) ) ); + + $this->addInvoiceToMaps( $invoice ); + + // If there's no payment import, try importing payment data from the invoices CSV. + if ( empty( $this->column_map['payment'] ) ) { + $payment_data = reset( $line_items ); + // Check for payment columns + if ( ! empty( $payment_data['payment.amount'] ) ) { + // Transform the payment to be saved + $payment_transformer = new PaymentTransformer( $this->maps ); + + /** @var PaymentRepository $payment_repository */ + $payment_repository = app()->make( PaymentRepository::class ); + $transformed_payment = $payment_transformer->transform( $payment_data ); + $transformed_payment['user_id'] = $invoice->user_id; + $transformed_payment['client_id'] = $invoice->client_id; + $transformed_payment['invoices'] = [ + [ + 'invoice_id' => $invoice->id, + 'amount' => $transformed_payment['amount'], + ], + ]; + + $payment_repository->save( + $transformed_payment, + PaymentFactory::create( $this->company->id, $invoice->user_id, $invoice->client_id ) + ); + } + } + + $this->actionInvoiceStatus( $invoice, $record['invoice.status']??null, $invoice_repository ); + } + } + + private function actionInvoiceStatus( $invoice, $status, $invoice_repository ) { + switch ( $status ) { + case 'Archived': + $invoice_repository->archive( $invoice ); + $invoice->fresh(); + break; + case 'Sent': + $invoice = $invoice->service()->markSent()->save(); + break; + case 'Viewed': + $invoice = $invoice->service()->markSent()->save(); + break; + default: + # code... + break; + } + + if($invoice->status_id <= Invoice::STATUS_SENT){ + if ( $invoice->balance < $invoice->amount) { + $invoice->status_id = Invoice::STATUS_PARTIAL; + $invoice->save(); + } elseif($invoice->balance <=0){ + $invoice->status_id = Invoice::STATUS_PAID; + $invoice->save(); + } + } + + + return $invoice; + } + + private function importEntities( $records, $entity_type ) { + $entity_type = Str::slug( $entity_type, '_' ); + $formatted_entity_type = Str::title( $entity_type ); + + $request = "\\App\\Http\\Requests\\${formatted_entity_type}\\Store${formatted_entity_type}Request"; + $repository_name = '\\App\\Repositories\\'.$formatted_entity_type . 'Repository'; + $transformer_name = '\\App\\Import\\Transformers\\'.$formatted_entity_type . 'Transformer'; + $factoryName = '\\App\\Factory\\'.$formatted_entity_type . 'Factory'; + + $repository = app()->make($repository_name); + $transformer = new $transformer_name( $this->maps ); + + if ( $this->skip_header ) { + array_shift( $records ); + } + + foreach ( $records as $record ) { + $keys = $this->column_map[ $entity_type ]; + $values = array_intersect_key( $record, $keys ); + + $data = array_combine( $keys, $values ); + + $entity = $transformer->transform( $data ); + + $validator = Validator::make( $entity, ( new $request() )->rules() ); + + if ( $validator->fails() ) { + $this->error_array[ $entity_type ][] = + [ $entity_type => $entity, 'error' => $validator->errors()->all() ]; + } else { + $entity = + $repository->save( $entity, $factoryName::create( $this->company->id, $this->getUserIDForRecord( $data) ) ); + + $entity->save(); + $this->{'add' . $formatted_entity_type . 'ToMaps'}( $entity ); + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + private function buildMaps() { + $this->maps = [ + 'company' => $this->company, + 'client' => [], + 'contact' => [], + 'invoice' => [], + 'invoice_client' => [], + 'product' => [], + 'countries' => [], + 'countries2' => [], + 'currencies' => [], + 'client_ids' => [], + 'invoice_ids' => [], + 'vendors' => [], + 'expense_categories' => [], + 'tax_rates' => [], + 'tax_names' => [], + ]; + + $clients = Client::scope()->get(); + foreach ( $clients as $client ) { + $this->addClientToMaps( $client ); + } + + $contacts = ClientContact::scope()->get(); + foreach ( $contacts as $contact ) { + $this->addContactToMaps( $contact ); + } + + $invoices = Invoice::scope()->get(); + foreach ( $invoices as $invoice ) { + $this->addInvoiceToMaps( $invoice ); + } + + $products = Product::scope()->get(); + foreach ( $products as $product ) { + $this->addProductToMaps( $product ); + } + + $countries = Country::all(); + foreach ( $countries as $country ) { + $this->maps['countries'][ strtolower( $country->name ) ] = $country->id; + $this->maps['countries2'][ strtolower( $country->iso_3166_2 ) ] = $country->id; + } + + $currencies = Currency::all(); + foreach ( $currencies as $currency ) { + $this->maps['currencies'][ strtolower( $currency->code ) ] = $currency->id; + } + + $vendors = Vendor::scope()->get(); + foreach ( $vendors as $vendor ) { + $this->addVendorToMaps( $vendor ); + } + + $expenseCaegories = ExpenseCategory::scope()->get(); + foreach ( $expenseCaegories as $category ) { + $this->addExpenseCategoryToMaps( $category ); + } + + $taxRates = TaxRate::scope()->get(); + foreach ( $taxRates as $taxRate ) { + $name = trim( strtolower( $taxRate->name ) ); + $this->maps['tax_rates'][ $name ] = $taxRate->rate; + $this->maps['tax_names'][ $name ] = $taxRate->name; + } + } + + /** + * @param Invoice $invoice + */ + private function addInvoiceToMaps( Invoice $invoice ) { + if ( $number = strtolower( trim( $invoice->invoice_number ) ) ) { + $this->maps['invoices'][ $number ] = $invoice; + $this->maps['invoice'][ $number ] = $invoice->id; + $this->maps['invoice_client'][ $number ] = $invoice->client_id; + $this->maps['invoice_ids'][ $invoice->public_id ] = $invoice->id; + } + } + + /** + * @param Client $client + */ + private function addClientToMaps( Client $client ) { + if ( $name = strtolower( trim( $client->name ) ) ) { + $this->maps['client'][ $name ] = $client->id; + $this->maps['client_ids'][ $client->public_id ] = $client->id; + } + if ( $client->contacts->count() ) { + $contact = $client->contacts[0]; + if ( $email = strtolower( trim( $contact->email ) ) ) { + $this->maps['client'][ $email ] = $client->id; + } + if ( $name = strtolower( trim($contact->first_name.' '.$contact->last_name) ) ) { + $this->maps['client'][ $name ] = $client->id; + } + $this->maps['client_ids'][ $client->public_id ] = $client->id; + } + } + + /** + * @param ClientContact $contact + */ + private function addContactToMaps( ClientContact $contact ) { + if ( $key = strtolower( trim( $contact->email ) ) ) { + $this->maps['contact'][ $key ] = $contact; + } + } + + /** + * @param Product $product + */ + private function addProductToMaps( Product $product ) { + if ( $key = strtolower( trim( $product->product_key ) ) ) { + $this->maps['product'][ $key ] = $product; + } + } + + private function addVendorToMaps( Vendor $vendor ) { + $this->maps['vendor'][ strtolower( $vendor->name ) ] = $vendor->id; + } + + private function addExpenseCategoryToMaps( ExpenseCategory $category ) { + if ( $name = strtolower( $category->name ) ) { + $this->maps['expense_category'][ $name ] = $category->id; + } + } + + + private function getUserIDForRecord( $record ) { + if ( !empty($record['client.user_id']) ) { + return $this->findUser( $record[ 'client.user_id' ] ); + } else { + return $this->company->owner()->id; + } + } + + private function findUser( $user_hash ) { + $user = User::where( 'company_id', $this->company->id ) + ->where( \DB::raw( 'CONCAT_WS(" ", first_name, last_name)' ), 'like', '%' . $user_hash . '%' ) + ->first(); + + if ( $user ) { + return $user->id; + } else { + return $this->company->owner()->id; + } + } + + private function getCsvData( $entityType ) { + $base64_encoded_csv = Cache::get( $this->hash . '-' . $entityType ); + if ( empty( $base64_encoded_csv ) ) { + return null; + } + + $csv = base64_decode( $base64_encoded_csv ); + $csv = Reader::createFromString( $csv ); + + $stmt = new Statement(); + $data = iterator_to_array( $stmt->process( $csv ) ); + + if ( count( $data ) > 0 ) { + $headers = $data[0]; + + // Remove Invoice Ninja headers + if ( count( $headers ) && count( $data ) > 4 && $this->import_type === 'csv' ) { + $firstCell = $headers[0]; + if ( strstr( $firstCell, config( 'ninja.app_name' ) ) ) { + array_shift( $data ); // Invoice Ninja... + array_shift( $data ); // + array_shift( $data ); // Enitty Type Header + } + } + } return $data; } diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php index 24f281c2410e..d8a087fb2aa7 100644 --- a/app/Models/BaseModel.php +++ b/app/Models/BaseModel.php @@ -20,6 +20,14 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException as ModelNotFoundException; use Illuminate\Support\Carbon; + +/** + * Class BaseModel + * + * @method scope() static + * + * @package App\Models + */ class BaseModel extends Model { use MakesHash; diff --git a/app/Models/ClientContact.php b/app/Models/ClientContact.php index 115fb1e206b0..62c486566bbe 100644 --- a/app/Models/ClientContact.php +++ b/app/Models/ClientContact.php @@ -23,6 +23,13 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Cache; use Laracasts\Presenter\PresentableTrait; +/** + * Class ClientContact + * + * @method scope() static + * + * @package App\Models + */ class ClientContact extends Authenticatable implements HasLocalePreference { use Notifiable; @@ -84,6 +91,27 @@ class ClientContact extends Authenticatable implements HasLocalePreference 'client_id', ]; + + /* + V2 type of scope + */ + public function scopeCompany($query) + { + $query->where('company_id', auth()->user()->companyId()); + + return $query; + } + + /* + V1 type of scope + */ + public function scopeScope($query) + { + $query->where($this->getTable().'.company_id', '=', auth()->user()->company()->id); + + return $query; + } + public function getEntityType() { return self::class; diff --git a/resources/views/email/import/completed.blade.php b/resources/views/email/import/completed.blade.php index 91c63b136012..28d3c78cbd08 100644 --- a/resources/views/email/import/completed.blade.php +++ b/resources/views/email/import/completed.blade.php @@ -1,4 +1,4 @@ -@component('email.template.master', ['design' => 'light', 'settings' => $settings]) +@component('email.template.master', ['design' => 'light', 'settings' => $company->settings]) @slot('header') @include('email.components.header', ['logo' => 'https://www.invoiceninja.com/wp-content/uploads/2015/10/logo-white-horizontal-1.png']) @endslot @@ -73,15 +73,32 @@

Documents Imported: {{ count($company->documents) }}

@endif + @if(!empty($errors) ) +

The following import errors occurred:

+ + + + + + + + + + @foreach($errors as $entityType=>$entityErrors) + @foreach($entityErrors as $error) + + + + + + @endforeach + @endforeach + +
TypeDataError
{{$entityType}}{{json_encode($error[$entityType]??null)}}{{json_encode($error['error'])}}
+ @endif + {{ ctrans('texts.account_login')}}

{{ ctrans('texts.email_signature')}}
{{ ctrans('texts.email_from') }}

-@if(!$whitelabel) - @slot('footer') - @component('email.components.footer', ['url' => 'https://invoiceninja.com', 'url_text' => '© InvoiceNinja']) - For any info, please visit InvoiceNinja. - @endcomponent - @endslot -@endif -@endcomponent \ No newline at end of file +@endcomponent