"""QR + EAN-13 для инвентарных этикеток оборудования.""" from __future__ import annotations import base64 import io from typing import Any import qrcode from barcode import get_barcode_class from barcode.writer import ImageWriter from PIL import Image _EAN_PREFIX = '200' TEXT_BLOCK_TYPES = frozenset({'model', 'type', 'inventory', 'serial', 'custom'}) CODE_BLOCK_TYPES = frozenset({'qr', 'ean13'}) def parse_label_codes(raw: str | None) -> str: """Нормализация: qr | ean | both.""" value = (raw or 'both').strip().lower() if value in ('qr', 'ean', 'both'): return value if value in ('ean13', 'barcode'): return 'ean' return 'both' def equipment_ean13_body(pk: int) -> str: """12 цифр без контрольной: 200 + pk (9 знаков).""" if pk < 1 or pk > 999_999_999: raise ValueError(f'pk вне диапазона для EAN-13: {pk}') return f'{_EAN_PREFIX}{pk:09d}' def equipment_ean13(pk: int) -> str: """Полный EAN-13 (13 цифр) для единицы оборудования.""" EAN13 = get_barcode_class('ean13') code = EAN13(equipment_ean13_body(pk), writer=ImageWriter()) return code.get_fullcode() def equipment_qr_payload(unit) -> str: """Что кодируем в QR: инв. №, иначе EQ{pk}.""" inv = (getattr(unit, 'inventory_number', None) or '').strip() if inv: return inv return f'EQ{unit.pk}' def _png_b64(img: Image.Image) -> str: buf = io.BytesIO() img.save(buf, format='PNG') return base64.b64encode(buf.getvalue()).decode('ascii') def qr_png_base64(payload: str, *, box_size: int = 4, border: int = 1) -> str: qr = qrcode.QRCode(version=None, box_size=box_size, border=border) qr.add_data(payload) qr.make(fit=True) img = qr.make_image(fill_color='black', back_color='white') if not isinstance(img, Image.Image): img = img.get_image() return _png_b64(img.convert('RGB')) def ean13_png_base64(pk: int) -> tuple[str, str]: """(png_base64, human_digits).""" EAN13 = get_barcode_class('ean13') code = EAN13( equipment_ean13_body(pk), writer=ImageWriter(), ) buf = io.BytesIO() code.write( buf, options={ 'module_width': 0.25, 'module_height': 10.0, 'quiet_zone': 2.0, 'font_size': 8, 'text_distance': 3.0, 'write_text': True, }, ) buf.seek(0) img = Image.open(buf).convert('RGB') return _png_b64(img), code.get_fullcode() def get_default_template(): from apps.it_manager.models import EquipmentLabelTemplate return EquipmentLabelTemplate.get_default() def _block_visible(block_type: str, codes: str) -> bool: if block_type in TEXT_BLOCK_TYPES: return True if block_type == 'qr': return codes in ('qr', 'both') if block_type == 'ean13': return codes in ('ean', 'both') return False def _text_for_block(block_type: str, block: dict, fields: dict[str, str]) -> str: if block_type == 'custom': return (block.get('text') or '').strip() if block_type == 'model': return fields['model'] if block_type == 'type': return fields['type_name'] if block_type == 'inventory': return fields['inventory'] or f"EQ{fields['id']}" if block_type == 'serial': serial = fields['serial'] return f'S/N {serial}' if serial else '' return '' def label_payload_for_unit(unit, *, codes: str = 'both', template=None) -> dict[str, Any]: codes = parse_label_codes(codes) if template is None: template = get_default_template() qr_text = equipment_qr_payload(unit) need_qr = codes in ('qr', 'both') need_ean = codes in ('ean', 'both') qr_png = qr_png_base64(qr_text) if need_qr else '' ean_b64, ean_digits = ('', '') if need_ean: ean_b64, ean_digits = ean13_png_base64(unit.pk) model = unit.equipment_model fields = { 'id': unit.pk, 'model': str(model) if model else '—', 'type_name': model.equipment_type.name if model and model.equipment_type_id else '', 'serial': unit.serial_number or '', 'inventory': (unit.inventory_number or '').strip(), } blocks_out = [] for raw in (template.layout or []): if not isinstance(raw, dict): continue btype = (raw.get('type') or '').strip() if not _block_visible(btype, codes): continue block = { 'id': raw.get('id') or btype, 'type': btype, 'x_mm': float(raw.get('x_mm') or 0), 'y_mm': float(raw.get('y_mm') or 0), 'w_mm': float(raw.get('w_mm') or 10), 'h_mm': float(raw.get('h_mm') or 10), 'font_pt': float(raw.get('font_pt') or 9), 'bold': bool(raw.get('bold')), 'align': (raw.get('align') or 'left'), 'text': '', 'png': '', } if btype == 'qr': block['png'] = qr_png block['text'] = qr_text elif btype == 'ean13': block['png'] = ean_b64 block['text'] = ean_digits else: block['text'] = _text_for_block(btype, raw, fields) blocks_out.append(block) return { 'id': unit.pk, 'model': fields['model'], 'type_name': fields['type_name'], 'serial': fields['serial'], 'inventory': fields['inventory'], 'qr_payload': qr_text, 'qr_png': qr_png, 'ean13': ean_digits, 'ean_png': ean_b64, 'codes': codes, 'width_mm': float(template.width_mm), 'height_mm': float(template.height_mm), 'blocks': blocks_out, } def normalize_layout(raw_blocks) -> list[dict]: """Валидация layout из редактора.""" allowed = CODE_BLOCK_TYPES | TEXT_BLOCK_TYPES out = [] if not isinstance(raw_blocks, list): return out for i, raw in enumerate(raw_blocks): if not isinstance(raw, dict): continue btype = (raw.get('type') or '').strip() if btype not in allowed: continue bid = (raw.get('id') or f'{btype}-{i}').strip()[:40] out.append({ 'id': bid, 'type': btype, 'x_mm': max(0.0, float(raw.get('x_mm') or 0)), 'y_mm': max(0.0, float(raw.get('y_mm') or 0)), 'w_mm': max(2.0, float(raw.get('w_mm') or 10)), 'h_mm': max(2.0, float(raw.get('h_mm') or 10)), 'font_pt': max(6.0, min(24.0, float(raw.get('font_pt') or 9))), 'bold': bool(raw.get('bold')), 'align': (raw.get('align') or 'left') if (raw.get('align') or 'left') in ( 'left', 'center', 'right', ) else 'left', 'text': (raw.get('text') or '')[:200] if btype == 'custom' else '', }) return out