-
Notifications
You must be signed in to change notification settings - Fork 0
feat: upload revenue file #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| <template> | ||
| <UForm | ||
| :validate="createValidator(uploadFileSchema)" | ||
| :state="state" | ||
| class="flex flex-col gap-3" | ||
| @submit="onSubmit" | ||
| > | ||
| <UFormField | ||
| label="Тип отчета" | ||
| name="type" | ||
| required | ||
| > | ||
| <USelectMenu | ||
| v-model="selectedType" | ||
| :items="availableTypes" | ||
| :placeholder="$t('common.select')" | ||
| size="xl" | ||
| class="w-full" | ||
| /> | ||
| </UFormField> | ||
|
|
||
| <UFormField | ||
| label="Файл" | ||
| name="file" | ||
| description="Не более 20 МБ" | ||
| required | ||
| > | ||
| <UFileUpload | ||
| v-model="state.file" | ||
| color="neutral" | ||
| highlight | ||
| label="Перетащите свой файл сюда" | ||
| description="XLSX" | ||
| class="min-h-28" | ||
| /> | ||
|
|
||
| <template #hint> | ||
| <UButton | ||
| to="/docs/examples/iiko-daily-revenue.jpg" | ||
| target="_blank" | ||
| variant="subtle" | ||
| color="neutral" | ||
| size="sm" | ||
| icon="i-lucide-file-text" | ||
| > | ||
| Пример файла-отчета | ||
| </UButton> | ||
| </template> | ||
| </UFormField> | ||
|
|
||
| <UButton | ||
| type="submit" | ||
| variant="solid" | ||
| color="secondary" | ||
| size="xl" | ||
| block | ||
| class="mt-3" | ||
| :label="$t('common.upload')" | ||
| /> | ||
| </UForm> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import type { FormSubmitEvent } from '@nuxt/ui' | ||
| import type { UploadFile } from '~~/shared/services/file' | ||
| import { uploadFileSchema } from '~~/shared/services/file' | ||
|
|
||
| const emit = defineEmits(['success', 'submitted']) | ||
|
|
||
| const { t } = useI18n() | ||
| const actionToast = useActionToast() | ||
|
|
||
| const postStore = usePostStore() | ||
|
|
||
| const state = ref<Partial<UploadFile>>({ | ||
| file: undefined, | ||
| }) | ||
|
|
||
| const availableTypes = [ | ||
| { label: 'Выручка магазинов из iiko за 1 день', value: 'iiko-daily' }, | ||
| ] | ||
| const selectedType = ref<typeof availableTypes[0]>() | ||
|
|
||
| async function onSubmit(event: FormSubmitEvent<UploadFile>) { | ||
| const toastId = actionToast.start() | ||
| emit('submitted') | ||
|
|
||
| try { | ||
| const formData = new FormData() | ||
| formData.append('file', event.data.file) | ||
|
|
||
| const data = await $fetch(`/api/kitchen/revenue/${selectedType.value?.value}`, { | ||
| method: 'POST', | ||
| body: formData, | ||
| }) | ||
|
|
||
| await postStore.update() | ||
|
|
||
| const errorMessage = data.result.errors.length > 0 ? `Ошибки: ${data.result.errors.join(', ')}` : '' | ||
|
|
||
| actionToast.success( | ||
| toastId, | ||
| t('toast.file-loaded'), | ||
| `Было добавлено ${data.result.rowsUpdated} ${pluralizationRu(data.result.rowsUpdated, ['запись', 'записи', 'записей'])}. ${errorMessage}`) | ||
| emit('success') | ||
| } catch (error) { | ||
| console.error(error) | ||
| actionToast.error(toastId) | ||
| } | ||
|
Comment on lines
+106
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Enhance error handling and logging. Console.error might not be appropriate for production, and users don't get specific error information. Consider implementing proper error logging and user feedback: } catch (error) {
- console.error(error)
+ const logger = useLogger('upload-kitchen-revenue')
+ logger.error('Failed to upload kitchen revenue file', error)
actionToast.error(toastId)
}
🤖 Prompt for AI Agents |
||
| } | ||
| </script> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <template> | ||
| <UModal title="Загрузка отчета"> | ||
| <template #body> | ||
| <FormUploadKitchenRevenue | ||
| @submitted="overlay.closeAll" | ||
| @success="overlay.closeAll" | ||
| /> | ||
| </template> | ||
| </UModal> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| const overlay = useOverlay() | ||
| </script> |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,10 @@ | ||||||||||||||||||||
| <template> | ||||||||||||||||||||
| <Content> | ||||||||||||||||||||
| <div>В разработке</div> | ||||||||||||||||||||
| <div>{{ data }}</div> | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Consider improving data presentation and error handling. Displaying raw data with - <div>{{ data }}</div>
+ <div v-if="pending">Loading...</div>
+ <div v-else-if="error">Error loading revenue data</div>
+ <div v-else-if="data">
+ <!-- Format revenue data appropriately -->
+ <pre>{{ JSON.stringify(data, null, 2) }}</pre>
+ </div>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| </Content> | ||||||||||||||||||||
| </template> | ||||||||||||||||||||
|
|
||||||||||||||||||||
| <script setup lang="ts"> | ||||||||||||||||||||
| const { params } = useRoute('kitchen-id') | ||||||||||||||||||||
| const { data } = useFetch(`/api/kitchen/id/${params.id}/revenue`) | ||||||||||||||||||||
| </script> | ||||||||||||||||||||
|
Comment on lines
+7
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Add error handling to the data fetching. The current implementation doesn't handle loading states or errors from the API call. -const { data } = useFetch(`/api/kitchen/id/${params.id}/revenue`)
+const { data, pending, error } = useFetch(`/api/kitchen/id/${params.id}/revenue`)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { repository } from '@roll-stack/database' | ||
|
|
||
| export default defineEventHandler(async (event) => { | ||
| const kitchenId = getRouterParam(event, 'kitchenId') | ||
| if (!kitchenId) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: 'Id is required', | ||
| }) | ||
| } | ||
|
|
||
| return repository.kitchen.listRevenuesByKitchen(kitchenId) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| import { repository } from '@roll-stack/database' | ||
| import xlsx from 'node-xlsx' | ||
|
|
||
| export default defineEventHandler(async (event) => { | ||
| try { | ||
| const logger = useLogger('kitchen-revenue-iiko-daily') | ||
|
|
||
| const files = await readMultipartFormData(event) | ||
| const file = files?.[0] | ||
| if (!files?.length || !file) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: 'Missing file', | ||
| }) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| const maxFileSize = 20 * 1024 * 1024 // 20MB | ||
| if (file.data.length > maxFileSize) { | ||
| throw createError({ | ||
| statusCode: 413, | ||
| message: 'File too large', | ||
| }) | ||
| } | ||
|
|
||
| const allowedMimeTypes = [ | ||
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | ||
| 'application/vnd.ms-excel', | ||
| ] | ||
| if (file.type && !allowedMimeTypes.includes(file.type)) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: 'Invalid file type', | ||
| }) | ||
| } | ||
|
|
||
| const workSheetsFromFile = xlsx.parse(file.data.buffer) | ||
| if (!workSheetsFromFile[0]) { | ||
| throw createError({ | ||
| statusCode: 404, | ||
| message: 'File not found', | ||
| }) | ||
| } | ||
|
|
||
| const data = workSheetsFromFile[0].data | ||
| const dateRow = data[2] // 3rd row | ||
| if (!dateRow || typeof dateRow[0] !== 'string' || !dateRow[0].startsWith('Дата')) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: 'Invalid date', | ||
| }) | ||
| } | ||
|
|
||
| const dateMatch = dateRow[0].match(/Дата:\s*(\d{1,2})\.(\d{1,2})\.(\d{4})/) | ||
| if (!dateMatch) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: 'Invalid date format. Expected "Дата: DD.MM.YYYY"', | ||
| }) | ||
| } | ||
|
|
||
| const [, day, month, year] = dateMatch | ||
| const dateOnly = `${year}-${month?.padStart(2, '0')}-${day?.padStart(2, '0')}` | ||
| const date = new Date(`${dateOnly}T12:00:00.000Z`) | ||
|
|
||
| if (Number.isNaN(date.getTime())) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: 'Invalid date values', | ||
| }) | ||
| } | ||
|
|
||
| // Remove first 4 rows and last row | ||
| const dataRows = data.slice(4, data.length - 1) | ||
| if (!dataRows) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: 'Invalid data', | ||
| }) | ||
| } | ||
|
|
||
| const parsedKitchens: { name: string, total: number }[] = [] | ||
|
|
||
| for (const row of dataRows) { | ||
| const name = row[2] // 3rd column | ||
| const total = row[4] // 5th column | ||
|
|
||
| if (typeof name !== 'string' || typeof total !== 'number') { | ||
| continue | ||
| } | ||
|
|
||
| parsedKitchens.push({ | ||
| name, | ||
| total, | ||
| }) | ||
| } | ||
|
|
||
| // Every kitchen: find in DB and add amount for this day | ||
| const kitchens = await repository.kitchen.list() | ||
| let rowsUpdated = 0 | ||
| const errors: string[] = [] | ||
|
|
||
| for (const kitchen of parsedKitchens) { | ||
| const found = kitchens.find((k) => k.iikoAlias === kitchen.name) | ||
| if (found) { | ||
| // Create or update | ||
| const revenue = await repository.kitchen.findRevenueByKitchenAndDate(found.id, date) | ||
| if (!revenue) { | ||
| await repository.kitchen.createRevenue({ | ||
| kitchenId: found.id, | ||
| date: dateOnly, | ||
| total: kitchen.total, | ||
| }) | ||
| } else { | ||
| await repository.kitchen.updateRevenue(revenue.id, { | ||
| total: kitchen.total, | ||
| }) | ||
| } | ||
|
|
||
| rowsUpdated++ | ||
| continue | ||
| } | ||
|
|
||
| logger.warn(`Kitchen "${kitchen.name}" from file not found`) | ||
| errors.push(`"${kitchen.name}" не найдена.`) | ||
| } | ||
|
Comment on lines
+97
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Wrap database operations in a transaction. Multiple database operations could leave the system in an inconsistent state if one fails partway through processing. Consider wrapping the database operations in a transaction: // Every kitchen: find in DB and add amount for this day
const kitchens = await repository.kitchen.list()
let rowsUpdated = 0
const errors: string[] = []
+ const db = useDatabase()
+ await db.transaction(async (tx) => {
for (const kitchen of parsedKitchens) {
const found = kitchens.find((k) => k.iikoAlias === kitchen.name)
if (found) {
// Create or update
const revenue = await repository.kitchen.findRevenueByKitchenAndDate(found.id, date)
if (!revenue) {
await repository.kitchen.createRevenue({
kitchenId: found.id,
date: dateOnly,
total: kitchen.total,
})
} else {
await repository.kitchen.updateRevenue(revenue.id, {
total: kitchen.total,
})
}
rowsUpdated++
continue
}
logger.warn(`Kitchen "${kitchen.name}" from file not found`)
errors.push(`"${kitchen.name}" не найдена.`)
}
+ })
🤖 Prompt for AI Agents |
||
|
|
||
| logger.log(rowsUpdated, date, parsedKitchens) | ||
|
|
||
| return { | ||
| ok: true, | ||
| result: { | ||
| rowsUpdated, | ||
| errors, | ||
| }, | ||
| } | ||
| } catch (error) { | ||
| throw errorResolver(error) | ||
| } | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { type } from 'arktype' | ||
|
|
||
| const MAX_FILE_SIZE = 20000000 | ||
| const ACCEPTED_FILE_TYPES = [ | ||
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | ||
| 'application/vnd.ms-excel', | ||
| ] | ||
|
|
||
| const FileSchema = type('File') | ||
| .narrow((file) => file.size <= MAX_FILE_SIZE && ACCEPTED_FILE_TYPES.includes(file.type)) | ||
| .describe('error.file-size-or-type') | ||
|
|
||
| export const uploadFileSchema = type({ | ||
| file: FileSchema, | ||
| }) | ||
| export type UploadFile = typeof uploadFileSchema.infer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Add explicit MIME type validation for enhanced security.
The file upload component only validates file extensions through the schema, but should also validate MIME types to prevent malicious file uploads disguised with valid extensions.
Consider adding MIME type validation:
<UFileUpload v-model="state.file" + accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" color="neutral" highlight label="Перетащите свой файл сюда" description="XLSX" class="min-h-28" />📝 Committable suggestion
🤖 Prompt for AI Agents