Files
crmApi/apps/shop/product_import.py
T
2026-10-08 16:37:45 +03:30

275 lines
14 KiB
Python

"""Bounded Excel parsing and transactional product import, shared by admin views."""
import hashlib
import json
from decimal import Decimal, InvalidOperation
from io import BytesIO
from zipfile import ZipFile
from django.core.exceptions import ValidationError
from django.db import DataError, IntegrityError, transaction
from django.db.models import Max
from openpyxl import load_workbook
from apps.shop.models import Product, ProductCategory
from apps.workspace.models import Workspace
MAX_ROWS = 5000
MAX_FILE_BYTES = 2 * 1024 * 1024
HEADERS = {
'name': ('name', 'محصول', 'نام محصول'),
'category': ('category', 'cate_name', 'دسته', 'مسیر دسته'),
'acc_code': ('acc_code', 'کد محصول', 'کد'),
'price': ('price', 'قیمت'),
'order_prd': ('order_prd', 'size', 'ترتیب نمایش محصول'),
'excel_order': ('excel_order', 'ترتیب اکسل محصول'),
'order_cat': ('order_cat', 'ترتیب نمایش دسته'),
'category_excel_order': ('category_excel_order', 'ترتیب اکسل دسته'),
'view_only': ('view_only', 'فقط نمایش'),
'attr': ('attr', 'ویژگی‌ها'),
'unit': ('unit', 'vahed', 'واحد'),
}
DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
def text_value(value):
if value is None:
return ''
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def integer_value(value, field, maximum=2147483647):
try:
number = Decimal(text_value(value).translate(DIGITS).replace(',', '').replace('٬', ''))
if not number.is_finite() or number != number.to_integral_value() or not 0 <= number <= maximum:
raise InvalidOperation
return int(number)
except (InvalidOperation, ValueError):
raise ValidationError({field: 'عدد صحیح نامنفی در محدوده مجاز وارد کنید.'})
def boolean_value(value):
normalized = text_value(value).translate(DIGITS).lower()
if normalized in ('1', 'true', 'yes', 'بله'):
return True
if normalized in ('0', 'false', 'no', 'خیر'):
return False
raise ValidationError({'view_only': 'مقدار بله/خیر یا true/false وارد کنید.'})
def parse_workbook(upload, sheet_name='', header_row=1, mapping=None):
"""Never evaluate formula cells; cap compressed and uncompressed workbook sizes."""
if not upload.name.lower().endswith('.xlsx'):
raise ValidationError('فقط فایل xlsx پذیرفته می‌شود؛ فایل xls را به xlsx تبدیل کنید.')
content = upload.read(MAX_FILE_BYTES + 1)
if len(content) > MAX_FILE_BYTES:
raise ValidationError('حداکثر حجم فایل ۲ مگابایت است.')
try:
with ZipFile(BytesIO(content)) as archive:
if sum(item.file_size for item in archive.infolist()) > 25 * 1024 * 1024:
raise ValidationError('حجم بازشده فایل بیش از حد مجاز است.')
book = load_workbook(BytesIO(content), read_only=True, data_only=False)
except ValidationError:
raise
except Exception:
# Workbook parsers raise several XML/ZIP-specific exception types.
raise ValidationError('فایل اکسل خراب است یا ساختار معتبر ندارد.')
try:
if sheet_name and sheet_name not in book.sheetnames:
raise ValidationError('برگه انتخاب‌شده در فایل وجود ندارد.')
sheet = book[sheet_name] if sheet_name else book.worksheets[0]
if sheet.max_row and sheet.max_row > header_row + MAX_ROWS:
raise ValidationError('حداکثر ۵۰۰۰ ردیف پس از سرستون مجاز است؛ ردیف‌های خالی انتهای فایل را حذف کنید.')
if sheet.max_column and sheet.max_column > 100:
raise ValidationError('حداکثر ۱۰۰ ستون مجاز است.')
iterator = sheet.iter_rows(min_row=header_row, max_row=header_row + MAX_ROWS + 1, max_col=100)
headers = [text_value(cell.value) for cell in next(iterator, ())]
indexes = resolve_headers(headers, mapping or {})
rows = []
for number, cells in enumerate(iterator, header_row + 1):
if not any(cell.value is not None for cell in cells):
continue
if number > header_row + MAX_ROWS:
raise ValidationError('حداکثر ۵۰۰۰ ردیف پس از سرستون مجاز است.')
values = {key: text_value(cells[index].value) for key, index in indexes.items()}
formulas = [key for key, index in indexes.items() if cells[index].data_type == 'f']
rows.append({'row': number, 'values': values, 'formulas': formulas})
if not rows:
raise ValidationError('فایل ردیف محصول ندارد.')
return rows
except ValidationError:
raise
except Exception:
raise ValidationError('محتوای برگه اکسل خراب است یا خوانده نمی‌شود.')
finally:
book.close()
def resolve_headers(headers, mapping):
if set(mapping) - set(HEADERS):
raise ValidationError('کلید نگاشت ستون ناشناخته است.')
indexes = {}
for key, aliases in HEADERS.items():
names = (mapping[key],) if key in mapping else aliases
matches = [index for index, name in enumerate(headers) if name and name in names]
if len(matches) > 1:
raise ValidationError(f'ستون {key} تکراری یا مبهم است.')
if matches:
indexes[key] = matches[0]
if len(set(indexes.values())) != len(indexes):
raise ValidationError('یک ستون به چند فیلد نگاشت شده است.')
if not {'name', 'category', 'acc_code'}.issubset(indexes):
raise ValidationError('ستون‌های نام محصول، دسته و کد محصول الزامی هستند.')
return indexes
def catalog_fingerprint(workspace):
"""Detect intervening writes between preview and confirmation."""
digest = hashlib.sha256()
for model in (ProductCategory, Product):
for item in model.objects.filter(sys_ws=workspace).order_by('pk').values().iterator():
digest.update(json.dumps(item, sort_keys=True, default=str, ensure_ascii=False).encode())
return digest.hexdigest()
def lock_catalog(workspace):
"""Lock existing rows in stable order as well as the workspace during confirmation."""
Workspace.objects.select_for_update().get(pk=workspace.pk)
for model in (ProductCategory, Product):
list(model.objects.select_for_update().filter(sys_ws=workspace).order_by('pk').values_list('pk', flat=True))
def category_for_path(workspace, path, create):
names = [name.strip() for name in path.split('/')]
if not names or len(names) > 10 or any(not name or len(name) > 60 for name in names):
raise ValidationError({'category': 'مسیر دسته با /، حداکثر ۱۰ سطح و نام‌های ۱ تا ۶۰ کاراکتری لازم است.'})
parent = None
for name in names:
matches = ProductCategory.objects.filter(sys_ws=workspace, name=name, parent=parent)
if matches.count() > 1:
raise ValidationError({'category': 'دسته تکراری و مبهم است؛ ابتدا دسته‌های موجود را اصلاح کنید.'})
category = matches.first()
if category is None:
if not create:
raise ValidationError({'category': f'دسته {name} وجود ندارد و ساخت دسته غیرفعال است.'})
category = ProductCategory(sys_ws=workspace, name=name, parent=parent)
category.full_clean()
category.save()
parent = category
return parent
def product_values(values):
fields = {'name': values.get('name', '').strip()}
if not fields['name']:
raise ValidationError({'name': 'نام محصول الزامی است.'})
for key in ('price', 'order_prd', 'excel_order'):
if values.get(key):
maximum = 9223372036854775807 if key == 'price' else 2147483647
fields[key] = integer_value(values[key], key, maximum)
if values.get('view_only'):
fields['view_only'] = boolean_value(values['view_only'])
if values.get('attr'):
try:
fields['attr'] = json.loads(values['attr'])
if not isinstance(fields['attr'], dict):
raise ValueError
except (ValueError, TypeError):
raise ValidationError({'attr': 'ویژگی‌ها باید یک شیء JSON معتبر باشد.'})
return fields
def apply_product_row(workspace, row, options):
values = row['values']
if row.get('formulas'):
raise ValidationError({key: 'فرمول مجاز نیست؛ مقدار ثابت جایگزین کنید.' for key in row['formulas']})
code = values.get('acc_code', '').strip()
if not code:
raise ValidationError({'acc_code': 'کد ثابت و غیرخالی محصول الزامی است.'})
matches = Product.objects.filter(sys_ws=workspace, acc_code=code)
if matches.count() > 1:
raise ValidationError({'acc_code': 'کد در دیتابیس تکراری است؛ تطبیق یکتا امکان‌پذیر نیست.'})
product = matches.first()
mode = options['mode']
if product is not None and mode == 'create':
raise ValidationError({'acc_code': 'محصول موجود است؛ حالت ایجاد اجازه به‌روزرسانی ندارد.'})
if product is None and mode == 'update':
raise ValidationError({'acc_code': 'محصول برای به‌روزرسانی پیدا نشد.'})
created = product is None
fields = product_values(values)
category = category_for_path(workspace, values.get('category', ''), options['create_categories'])
changed_order = False
for source, target in (('order_cat', 'order_cat'), ('category_excel_order', 'excel_order')):
if values.get(source):
setattr(category, target, integer_value(values[source], source))
changed_order = True
if changed_order:
category.full_clean()
category.save()
product = product or Product(sys_ws=workspace, acc_code=code, price=0)
product.category = category
for key, value in fields.items():
setattr(product, key, value)
if values.get('unit'):
product.attr = dict(product.attr or {}, acc_vahed=values['unit'])
set_product_order(product, values, options['ordering'])
product.full_clean()
product.save()
return 'created' if created else 'updated'
def set_product_order(product, values, ordering):
if ordering == 'append' and product.pk is None and not values.get('order_prd'):
maximum = Product.objects.filter(category=product.category).aggregate(value=Max('order_prd'))['value']
product.order_prd = (maximum or 0) + 1
def import_products(workspace, rows, options, dry_run=False):
"""Each rejected row rolls back its categories too; atomic mode rolls back all rows."""
if options.get('mode') not in ('create', 'update', 'upsert') or options.get('errors') not in ('atomic', 'skip') or options.get('ordering') not in ('preserve', 'rows', 'append') or not isinstance(options.get('create_categories'), bool):
raise ValidationError('تنظیمات ورود معتبر نیست.')
results, seen, category_orders = [], set(), {}
with transaction.atomic():
Workspace.objects.select_for_update().get(pk=workspace.pk)
for position, row in enumerate(rows, 1):
previous_orders = dict(category_orders)
values = dict(row['values'])
if options['ordering'] == 'rows' and not values.get('order_prd'):
values['order_prd'] = str(position)
candidate = dict(row, values=values)
try:
with transaction.atomic():
code = values.get('acc_code', '').strip()
if code in seen:
raise ValidationError({'acc_code': 'کد در همین فایل تکراری است.'})
seen.add(code)
check_category_orders(values, category_orders)
status = apply_product_row(workspace, candidate, options)
results.append(dict(row, status=status, errors={}))
except ValidationError as error:
category_orders = previous_orders
errors = error.message_dict if hasattr(error, 'message_dict') else {'row': error.messages}
results.append(dict(row, status='error', errors=errors))
except (IntegrityError, DataError):
category_orders = previous_orders
results.append(dict(row, status='error', errors={'row': ['تعارض دیتابیس؛ اطلاعات یا کد تکراری را بررسی کنید.']}))
has_errors = any(result['status'] == 'error' for result in results)
if dry_run or (has_errors and options['errors'] == 'atomic'):
transaction.set_rollback(True)
return {'rows': results, 'errors': sum(row['status'] == 'error' for row in results),
'created': sum(row['status'] == 'created' for row in results),
'updated': sum(row['status'] == 'updated' for row in results),
'committed': not dry_run and not (has_errors and options['errors'] == 'atomic')}
def check_category_orders(values, orders):
path = '/'.join(part.strip() for part in values.get('category', '').split('/'))
for key in ('order_cat', 'category_excel_order'):
if values.get(key):
number = integer_value(values[key], key)
previous = orders.get((path, key), number)
if previous != number:
raise ValidationError({key: 'ترتیب همین دسته در ردیف‌های فایل ناسازگار است.'})
orders[(path, key)] = number