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
21 changes: 11 additions & 10 deletions apps/web-app/app/components/form/UploadKitchenRevenue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@
</UFormField>

<UFormField
label="Файл"
name="file"
label="Файлы"
name="files"
description="Не более 20 МБ"
required
>
<UFileUpload
v-model="state.file"
v-model="state.files"
color="neutral"
highlight
label="Перетащите свой файл сюда"
multiple
label="Перетащите свои файлы сюда"
description="XLSX"
class="min-h-28"
/>
Expand Down Expand Up @@ -73,7 +74,7 @@ const actionToast = useActionToast()
const postStore = usePostStore()

const state = ref<Partial<UploadFile>>({
file: undefined,
files: [],
})

const availableTypes = [
Expand All @@ -87,7 +88,9 @@ async function onSubmit(event: FormSubmitEvent<UploadFile>) {

try {
const formData = new FormData()
formData.append('file', event.data.file)
for (const file of event.data.files) {
formData.append('files', file)
}

const data = await $fetch(`/api/kitchen/revenue/${selectedType.value?.value}`, {
method: 'POST',
Expand All @@ -97,11 +100,9 @@ async function onSubmit(event: FormSubmitEvent<UploadFile>) {
await postStore.update()

const errorMessage = data.result.errors.length > 0 ? `Ошибки: ${data.result.errors.join(', ')}` : ''
const description = `Было обновлено ${data.result.rowsUpdated} ${pluralizationRu(data.result.rowsUpdated, ['запись', 'записи', 'записей'])}. ${errorMessage}`

actionToast.success(
toastId,
t('toast.file-loaded'),
`Было добавлено ${data.result.rowsUpdated} ${pluralizationRu(data.result.rowsUpdated, ['запись', 'записи', 'записей'])}. ${errorMessage}`)
actionToast.success(toastId, t('toast.files-handled'), description)
emit('success')
} catch (error) {
console.error(error)
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 @@ -338,6 +338,7 @@
"variant-deleted": "Вариация продукта удалена",
"photo-loaded": "Фото загружено",
"file-loaded": "Файл загружен",
"files-handled": "Файлы обработаны",
"category-updated": "Категория обновлена",
"category-deleted": "Категория удалена",
"task-created": "Задача создана",
Expand Down
248 changes: 142 additions & 106 deletions apps/web-app/server/api/kitchen/revenue/iiko-daily.post.ts
Original file line number Diff line number Diff line change
@@ -1,139 +1,175 @@
import type { Buffer } from 'node:buffer'
import { ACCEPTED_FILE_TYPES, MAX_FILE_SIZE } from '#shared/services/file'
import { repository } from '@roll-stack/database'
import xlsx from 'node-xlsx'

interface MultiPartData {
data: Buffer
name?: string
filename?: string
type?: string
}

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) {
if (!files?.length) {
throw createError({
statusCode: 400,
message: 'Missing file',
message: 'Missing files',
})
}

const maxFileSize = 20 * 1024 * 1024 // 20MB
if (file.data.length > maxFileSize) {
throw createError({
statusCode: 413,
message: 'File too large',
})
}
let rowsUpdated = 0
const errors: string[] = []

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',
})
for (const file of files) {
const res = await parseFileAndUpdateData(file)
rowsUpdated += res.rowsUpdated
errors.push(...res.errors)
}

const workSheetsFromFile = xlsx.parse(file.data.buffer)
if (!workSheetsFromFile[0]) {
throw createError({
statusCode: 404,
message: 'File not found',
})
return {
ok: true,
result: {
rowsUpdated,
errors,
},
}
} catch (error) {
throw errorResolver(error)
}
})

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',
})
}
async function parseFileAndUpdateData(file: MultiPartData) {
const logger = useLogger('kitchen-revenue-iiko-daily')

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"',
})
}
if (file.data.length > MAX_FILE_SIZE) {
throw createError({
statusCode: 413,
message: 'File too large',
})
}

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 (file.type && !ACCEPTED_FILE_TYPES.includes(file.type)) {
throw createError({
statusCode: 400,
message: 'Invalid file type',
})
}

if (Number.isNaN(date.getTime())) {
throw createError({
statusCode: 400,
message: 'Invalid date values',
})
}
const workSheetsFromFile = xlsx.parse(file.data.buffer)
if (!workSheetsFromFile[0]) {
throw createError({
statusCode: 404,
message: 'File not found',
})
}

// 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 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 parsedKitchens: { name: string, total: number }[] = []
const dictionary = data[3]
if (!dictionary) {
throw createError({
statusCode: 400,
message: 'Invalid dictionary',
})
}

for (const row of dataRows) {
const name = row[2] // 3rd column
const total = row[4] // 5th column
const indexOfName = dictionary.indexOf('Группа')
const indexOfTotal = dictionary.indexOf('Сумма со скидкой, р. Всего')
if (!dictionary || indexOfName < 0 || indexOfTotal < 0) {
throw createError({
statusCode: 400,
message: 'Invalid dictionary',
})
}

if (typeof name !== 'string' || typeof total !== 'number') {
continue
}
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"',
})
}

parsedKitchens.push({
name,
total,
})
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[indexOfName]
const total = row[indexOfTotal]

if (typeof name !== 'string' || typeof total !== 'number') {
continue
}

// Every kitchen: find in DB and add amount for this day
const kitchens = await repository.kitchen.list()
let rowsUpdated = 0
const errors: string[] = []
parsedKitchens.push({
name,
total,
})
}

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
// 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,
})
}

logger.warn(`Kitchen "${kitchen.name}" from file not found`)
errors.push(`"${kitchen.name}" не найдена.`)
rowsUpdated++
continue
}

logger.log(rowsUpdated, date, parsedKitchens)
logger.warn(`Kitchen "${kitchen.name}" from file not found`)
errors.push(`"${kitchen.name}" не найдена.`)
}
Comment on lines +144 to +167
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 adding database transaction support for better error handling.

Each file's database operations are not wrapped in a transaction. If an error occurs partway through processing a file's data, some records might be updated while others aren't, leading to inconsistent state.

Consider wrapping the database operations for each file in a transaction:

  for (const kitchen of parsedKitchens) {
    const found = kitchens.find((k) => k.iikoAlias === kitchen.name)
    if (found) {
+     // Wrap in transaction if supported by your repository
+     await repository.transaction(async (tx) => {
        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
    }
📝 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
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,
})
}
logger.warn(`Kitchen "${kitchen.name}" from file not found`)
errors.push(`"${kitchen.name}" не найдена.`)
rowsUpdated++
continue
}
logger.log(rowsUpdated, date, parsedKitchens)
logger.warn(`Kitchen "${kitchen.name}" from file not found`)
errors.push(`"${kitchen.name}" не найдена.`)
}
for (const kitchen of parsedKitchens) {
const found = kitchens.find((k) => k.iikoAlias === kitchen.name)
if (found) {
// Wrap in transaction if supported by your repository
await repository.transaction(async (tx) => {
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
In apps/web-app/server/api/kitchen/revenue/iiko-daily.post.ts around lines 149
to 172, the database operations for each kitchen are not wrapped in a
transaction, risking partial updates and inconsistent state if an error occurs.
To fix this, wrap the entire loop or the processing of each file's data in a
database transaction using your repository or ORM's transaction API. Begin a
transaction before processing, commit it after all operations succeed, and roll
back if any error occurs to ensure atomicity and consistency.


return {
ok: true,
result: {
rowsUpdated,
errors,
},
}
} catch (error) {
throw errorResolver(error)
logger.log(rowsUpdated, date, parsedKitchens)

return {
rowsUpdated,
errors,
}
})
}
6 changes: 3 additions & 3 deletions apps/web-app/shared/services/file.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type } from 'arktype'

const MAX_FILE_SIZE = 20000000
const ACCEPTED_FILE_TYPES = [
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ACCEPTED_FILE_TYPES = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
]
Expand All @@ -11,6 +11,6 @@ const FileSchema = type('File')
.describe('error.file-size-or-type')

export const uploadFileSchema = type({
file: FileSchema,
files: FileSchema.array(),
})
export type UploadFile = typeof uploadFileSchema.infer