d54f6fbe42
Co-authored-by: Cursor <cursoragent@cursor.com>
220 lines
6.9 KiB
Python
220 lines
6.9 KiB
Python
"""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
|