Merge pull request #7840 from aignatov-bio/ai-sci-10954-import-items-to-box

Add import/export for box [SCI-10954]
This commit is contained in:
aignatov-bio 2024-09-10 15:34:28 +02:00 committed by GitHub
commit 8d5adad6b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 323 additions and 6 deletions

View file

@ -2,10 +2,10 @@
class StorageLocationsController < ApplicationController
before_action :check_storage_locations_enabled, except: :unassign_rows
before_action :load_storage_location, only: %i(update destroy duplicate move show available_positions unassign_rows)
before_action :load_storage_location, only: %i(update destroy duplicate move show available_positions unassign_rows export_container import_container)
before_action :check_read_permissions, except: %i(index create tree actions_toolbar)
before_action :check_create_permissions, only: :create
before_action :check_manage_permissions, only: %i(update destroy duplicate move unassign_rows)
before_action :check_manage_permissions, only: %i(update destroy duplicate move unassign_rows import_container)
before_action :set_breadcrumbs_items, only: %i(index show)
def index
@ -98,6 +98,25 @@ class StorageLocationsController < ApplicationController
render json: { status: :ok }
end
def export_container
xlsx = StorageLocations::ExportService.new(@storage_location, current_user).to_xlsx
send_data(
xlsx,
filename: "#{@storage_location.name.gsub(/\s/, '_')}_export_#{Date.current}.xlsx",
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
end
def import_container
result = StorageLocations::ImportService.new(@storage_location, params[:file], current_user).import_items
if result[:status] == :ok
render json: result
else
render json: result, status: :unprocessable_entity
end
end
def actions_toolbar
render json: {
actions:

View file

@ -32,6 +32,7 @@
</template>
<script>
/* global GLOBAL_CONSTANTS I18n */
export default {
name: 'DragAndDropUpload',
@ -69,7 +70,9 @@ export default {
// check if it's a correct file type
const fileExtension = file.name.split('.')[1];
if (!this.supportedFormats.includes(fileExtension)) {
const error = I18n.t('repositories.import_records.dragAndDropUpload.wrongFileTypeError');
const error = I18n.t('repositories.import_records.dragAndDropUpload.wrongFileTypeError', {
extensions: this.supportedFormats.join(', ')
});
this.$emit('file:error', error);
return false;
}

View file

@ -20,6 +20,7 @@
:scrollMode="paginationMode"
@assign="assignRow"
@move="moveRow"
@import="openImportModal = true"
@unassign="unassignRows"
@tableReloaded="handleTableReload"
@selectionChanged="selectedItems = $event"
@ -35,6 +36,12 @@
:cellId="cellIdToUnassign"
@close="openAssignModal = false; this.reloadingTable = true"
></AssignModal>
<ImportModal
v-if="openImportModal"
:containerId="containerId"
@close="openImportModal = false"
@reloadTable="reloadingTable = true"
></ImportModal>
<ConfirmationModal
:title="i18n.t('storage_locations.show.unassign_modal.title')"
:description="storageLocationUnassignDescription"
@ -53,6 +60,7 @@ import axios from '../../packs/custom_axios.js';
import DataTable from '../shared/datatable/table.vue';
import Grid from './grid.vue';
import AssignModal from './modals/assign.vue';
import ImportModal from './modals/import.vue';
import ConfirmationModal from '../shared/confirmation_modal.vue';
import RemindersRender from './renderers/reminders.vue';
@ -63,7 +71,8 @@ export default {
Grid,
AssignModal,
ConfirmationModal,
RemindersRender
RemindersRender,
ImportModal
},
props: {
canManage: {
@ -92,6 +101,7 @@ export default {
return {
reloadingTable: false,
openEditModal: false,
openImportModal: false,
editModalMode: null,
editStorageLocation: null,
objectToMove: null,
@ -117,7 +127,7 @@ export default {
field: 'position_formatted',
headerName: this.i18n.t('storage_locations.show.table.position'),
sortable: true,
notSelectable: true,
notSelectable: true
},
{
field: 'reminders',
@ -157,6 +167,14 @@ export default {
});
}
left.push({
name: 'import',
icon: 'sn-icon sn-icon-import',
label: this.i18n.t('storage_locations.show.import_modal.import_button'),
type: 'emit',
buttonStyle: 'btn btn-light'
});
return {
left,
right: []

View file

@ -0,0 +1,118 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate" id="edit-project-modal-label">
{{ i18n.t('storage_locations.show.import_modal.title') }}
</h4>
</div>
<div class="modal-body flex flex-col grow">
<p>
{{ i18n.t('storage_locations.show.import_modal.description') }}
</p>
<h3 class="my-0 text-sn-dark-grey mb-3">
{{ i18n.t('storage_locations.show.import_modal.export') }}
</h3>
<div class="flex gap-4 mb-6">
<a
:href="exportUrl"
target="_blank"
class="btn btn-secondary btn-sm"
>
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('storage_locations.show.import_modal.export_button') }}
</a>
</div>
<h3 class="my-0 text-sn-dark-grey mb-3">
{{ i18n.t('storage_locations.show.import_modal.import') }}
</h3>
<DragAndDropUpload
class="h-60"
@file:dropped="uploadFile"
@file:error="handleError"
@file:error:clear="this.error = null"
:supportingText="`${i18n.t('storage_locations.show.import_modal.drag_n_drop')}`"
:supportedFormats="['xlsx']"
/>
</div>
<div class="modal-footer">
<div v-if="error" class="flex flex-row gap-2 my-auto mr-auto text-sn-delete-red">
<i class="sn-icon sn-icon-alert-warning"></i>
<div class="my-auto">{{ error }}</div>
</div>
<button class="btn btn-secondary" @click="close" aria-label="Close">
{{ i18n.t('general.cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import DragAndDropUpload from '../../shared/drag_and_drop_upload.vue';
import modalMixin from '../../shared/modal_mixin';
import axios from '../../../packs/custom_axios';
import {
export_container_storage_location_path,
import_container_storage_location_path
} from '../../../routes.js';
export default {
name: 'ImportContainer',
emits: ['uploadFile', 'close'],
components: {
DragAndDropUpload
},
mixins: [modalMixin],
props: {
containerId: {
type: Number,
required: true
}
},
data() {
return {
error: null
};
},
computed: {
exportUrl() {
return export_container_storage_location_path({
id: this.containerId
});
},
importUrl() {
return import_container_storage_location_path({
id: this.containerId
});
}
},
methods: {
handleError(error) {
this.error = error;
},
uploadFile(file) {
const formData = new FormData();
// required payload
formData.append('file', file);
axios.post(this.importUrl, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
.then(() => {
this.$emit('reloadTable');
this.close();
}).catch((error) => {
this.handleError(error.response.data.message);
});
}
}
};
</script>

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'caxlsx'
module StorageLocations
class ExportService
include Canaid::Helpers::PermissionsHelper
def initialize(storage_location, user)
@storage_location = storage_location
@user = user
end
def to_xlsx
package = Axlsx::Package.new
workbook = package.workbook
workbook.add_worksheet(name: 'Box Export') do |sheet|
sheet.add_row ['Box position', 'Item ID', 'Item name']
@storage_location.storage_location_repository_rows.each do |storage_location_item|
row = storage_location_item.repository_row
row_name = row.name if can_read_repository?(@user, row.repository)
sheet.add_row [format_position(storage_location_item), storage_location_item.repository_row_id, row_name]
end
end
package.to_stream.read
end
private
def format_position(item)
position = item.metadata['position']
return unless position
column_letter = ('A'..'Z').to_a[position[0] - 1]
row_number = position[1]
"#{column_letter}#{row_number}"
end
end
end

View file

@ -0,0 +1,102 @@
# frozen_string_literal: true
require 'caxlsx'
module StorageLocations
class ImportService
def initialize(storage_location, file, user)
@storage_location = storage_location
@file = file
@user = user
end
def import_items
sheet = SpreadsheetParser.open_spreadsheet(@file)
incoming_items = SpreadsheetParser.spreadsheet_enumerator(sheet).reject { |r| r.all?(&:blank?) }
# Check if the file has proper headers
header = SpreadsheetParser.parse_row(incoming_items[0], sheet)
return { status: :error, message: I18n.t('storage_locations.show.import_modal.errors.invalid_structure') } unless header[0] == 'Box position' && header[1] == 'Item ID'
# Remove first row
incoming_items.shift
incoming_items.map! { |r| SpreadsheetParser.parse_row(r, sheet) }
# Check duplicate positions in the file
if @storage_location.with_grid? && incoming_items.pluck(0).uniq.length != incoming_items.length
return { status: :error, message: I18n.t('storage_locations.show.import_modal.errors.invalid_position') }
end
existing_items = @storage_location.storage_location_repository_rows.map do |item|
[convert_position_number_to_letter(item), item.repository_row_id, item.id]
end
items_to_unassign = []
existing_items.each do |existing_item|
if incoming_items.any? { |r| r[0] == existing_item[0] && r[1].to_i == existing_item[1] }
incoming_items.reject! { |r| r[0] == existing_item[0] && r[1].to_i == existing_item[1] }
else
items_to_unassign << existing_item[2]
end
end
error_message = ''
ActiveRecord::Base.transaction do
@storage_location.storage_location_repository_rows.where(id: items_to_unassign).discard_all
incoming_items.each do |row|
if @storage_location.with_grid?
position = convert_position_letter_to_number(row[0])
unless position[0].to_i <= @storage_location.grid_size[0].to_i && position[1].to_i <= @storage_location.grid_size[1].to_i
error_message = I18n.t('storage_locations.show.import_modal.errors.invalid_position')
raise ActiveRecord::RecordInvalid
end
end
repository_row = RepositoryRow.find_by(id: row[1])
unless repository_row
error_message = I18n.t('storage_locations.show.import_modal.errors.invalid_item', row_id: row[1].to_i)
raise ActiveRecord::RecordNotFound
end
@storage_location.storage_location_repository_rows.create!(
repository_row: repository_row,
metadata: { position: position },
created_by: @user
)
end
rescue ActiveRecord::RecordNotFound
return { status: :error, message: error_message }
end
{ status: :ok }
end
private
def convert_position_letter_to_number(position)
return unless position
column_letter = position[0]
row_number = position[1]
[column_letter.ord - 64, row_number]
end
def convert_position_number_to_letter(item)
position = item.metadata['position']
return unless position
column_letter = ('A'..'Z').to_a[position[0] - 1]
row_number = position[1]
"#{column_letter}#{row_number}"
end
end
end

View file

@ -2341,7 +2341,7 @@ en:
linkTo: 'https://knowledgebase.scinote.net/en/knowledge/how-to-add-items-to-an-inventory'
dragAndDropUpload:
notSingleFileError: 'Single file import only. Please import one file at a time.'
wrongFileTypeError: 'The file has invalid extension (.csv, .xlsx, .txt or .tsv.)'
wrongFileTypeError: 'The file has invalid extension (%{extensions}).'
emptyFileError: 'You have uploaded empty file. There is not much to import.'
fileTooLargeError: 'File too large. Max file size limit is'
importText:
@ -2698,6 +2698,18 @@ en:
column: 'Column'
inventory: 'Inventory'
item: 'Item'
import_modal:
import_button: 'Import items'
title: "Import items to a box"
description: "Import items to a box allows for assigning items to a box and updating positions within a grid box. First, export the current box data to download a file listing the items already in the box. Then, edit the exported file to add or update the items you want to place in the box. When importing the file, ensure it includes the Position and Item ID columns for a successful import."
export: "Export"
export_button: "Export current box"
import: "Import"
drag_n_drop: ".xlsx file"
errors:
invalid_structure: "The imported file content doesn't meet criteria."
invalid_position: "Positions in the file must match with the box."
invalid_item: "Item ID %{row_id} doesn't exist."
index:
head_title: "Locations"
new_location: "New location"

View file

@ -828,6 +828,8 @@ Rails.application.routes.draw do
post :unassign_rows
get :available_positions
get :shareable_teams
get :export_container
post :import_container
end
resources :storage_location_repository_rows, only: %i(index create destroy update) do
collection do