Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/web-app/app/components/form/UploadKitchenRevenue.vue
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"
/>
Comment on lines +28 to +35
Copy link
Copy Markdown

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<UFileUpload
v-model="state.file"
color="neutral"
highlight
label="Перетащите свой файл сюда"
description="XLSX"
class="min-h-28"
/>
<UFileUpload
v-model="state.file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
color="neutral"
highlight
label="Перетащите свой файл сюда"
description="XLSX"
class="min-h-28"
/>
🤖 Prompt for AI Agents
In apps/web-app/app/components/form/UploadKitchenRevenue.vue around lines 28 to
35, the UFileUpload component currently only validates file extensions but lacks
MIME type validation, which is a security risk. Enhance the file validation by
adding explicit MIME type checks in the component or the form validation schema
to ensure only allowed MIME types (e.g., for XLSX files) are accepted. This can
be done by adding a MIME type validation rule or prop to the UFileUpload
component or by validating the file's MIME type in the form submission handler.


<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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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)
  }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web-app/app/components/form/UploadKitchenRevenue.vue around lines 106 to
109, replace the generic console.error call with a more robust error logging
mechanism suitable for production, such as sending the error details to a
centralized logging service. Additionally, improve user feedback by providing a
specific and informative error message through the actionToast.error method
instead of just passing toastId, so users understand what went wrong.

}
</script>
14 changes: 14 additions & 0 deletions apps/web-app/app/components/modal/UploadKitchenRevenue.vue
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>
7 changes: 6 additions & 1 deletion apps/web-app/app/pages/kitchen/[id]/finance.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<template>
<Content>
<div>В разработке</div>
<div>{{ data }}</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 {{ data }} will show JSON output, which isn't user-friendly. Consider formatting the revenue data appropriately for the UI.

-    <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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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>
🤖 Prompt for AI Agents
In apps/web-app/app/pages/kitchen/[id]/finance.vue at line 3, the raw data is
displayed directly using {{ data }}, which results in unformatted JSON output
that is not user-friendly. Replace this with properly formatted presentation of
the revenue data, such as displaying key fields with labels and formatting
numbers as currency. Additionally, add error handling to display a user-friendly
message if the data is missing or invalid.

</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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<script setup lang="ts">
const { params } = useRoute('kitchen-id')
const { data } = useFetch(`/api/kitchen/id/${params.id}/revenue`)
</script>
<script setup lang="ts">
const { params } = useRoute('kitchen-id')
-const { data } = useFetch(`/api/kitchen/id/${params.id}/revenue`)
+const { data, pending, error } = useFetch(`/api/kitchen/id/${params.id}/revenue`)
</script>
🤖 Prompt for AI Agents
In apps/web-app/app/pages/kitchen/[id]/finance.vue around lines 7 to 10, the
data fetching using useFetch lacks error handling and loading state management.
Update the code to destructure and handle loading and error states from
useFetch, such as adding variables for isLoading and error, and implement
conditional logic or UI feedback to manage these states appropriately during the
API call lifecycle.

36 changes: 31 additions & 5 deletions apps/web-app/app/pages/kitchen/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
<template>
<Header :title="t('app.menu.kitchens')" />
<Header :title="t('app.menu.kitchens')">
<UButton
size="lg"
variant="solid"
color="secondary"
class="w-full md:w-fit"
icon="i-lucide-upload"
label="Добавить выручку"
@click="modalUploadKitchenRevenue.open()"
/>
</Header>

<Content>
<div class="flex flex-wrap items-center justify-between gap-1.5">
Expand Down Expand Up @@ -96,8 +106,8 @@
{{ row.getValue('address') }}, {{ row.getValue('city') }}
</div>
</template>
<template #no-cell="">
<div>??? руб</div>
<template #revenueForThisWeek-cell="{ row }">
<div>{{ row.getValue('revenueForThisWeek') }} руб</div>
</template>
<template #action-cell="{ row }">
<div class="flex items-end" data-action="true">
Expand Down Expand Up @@ -140,6 +150,7 @@
<script setup lang="ts">
import type { DropdownMenuItem, TableColumn } from '@nuxt/ui'
import type { Kitchen } from '@roll-stack/database'
import { ModalUploadKitchenRevenue } from '#components'
import { getPaginationRowModel } from '@tanstack/table-core'
import { upperFirst } from 'scule'

Expand Down Expand Up @@ -222,8 +233,20 @@ const columns: Ref<TableColumn<KitchenWithData>[]> = ref([{
accessorKey: 'city',
header: 'Населенный пункт',
}, {
accessorKey: 'no',
header: 'Выручка',
accessorKey: 'revenueForThisWeek',
header: ({ column }) => {
const isSorted = column.getIsSorted()
const icon = isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow'

return h(UButton, {
color: 'neutral',
variant: 'ghost',
label: 'Выручка за неделю',
icon: isSorted ? icon : 'i-lucide-arrow-up-down',
class: '-mx-2.5',
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
})
},
}, {
id: 'action',
enableSorting: false,
Expand All @@ -249,6 +272,9 @@ function getDropdownActions(_: Kitchen): DropdownMenuItem[][] {

const table = useTemplateRef('table')

const overlay = useOverlay()
const modalUploadKitchenRevenue = overlay.create(ModalUploadKitchenRevenue)

useHead({
title: t('app.menu.kitchens'),
})
Expand Down
1 change: 1 addition & 0 deletions apps/web-app/i18n/locales/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@
"variant-updated": "Вариация продукта обновлена",
"variant-deleted": "Вариация продукта удалена",
"photo-loaded": "Фото загружено",
"file-loaded": "Файл загружен",
"category-updated": "Категория обновлена",
"category-deleted": "Категория удалена",
"task-created": "Задача создана",
Expand Down
1 change: 1 addition & 0 deletions apps/web-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"date-fns": "catalog:",
"ioredis": "catalog:",
"libphonenumber-js": "catalog:",
"node-xlsx": "catalog:",
"nuxt-tiptap-editor": "catalog:",
"openai": "catalog:",
"pinia": "catalog:",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions apps/web-app/server/api/kitchen/id/[kitchenId]/revenue.get.ts
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)
})
139 changes: 139 additions & 0 deletions apps/web-app/server/api/kitchen/revenue/iiko-daily.post.ts
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',
})
}
Comment thread
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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}" не найдена.`)
    }
+ })

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web-app/server/api/kitchen/revenue/iiko-daily.post.ts around lines 78 to
106, the database operations for creating or updating kitchen revenue are not
wrapped in a transaction, risking partial updates if an error occurs. Refactor
the code to wrap the entire loop of database operations inside a single
transaction using the repository or database client's transaction API. This
ensures all updates succeed or fail atomically, maintaining data consistency.


logger.log(rowsUpdated, date, parsedKitchens)

return {
ok: true,
result: {
rowsUpdated,
errors,
},
}
} catch (error) {
throw errorResolver(error)
}
})
16 changes: 16 additions & 0 deletions apps/web-app/shared/services/file.ts
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
Loading