Files
ACORE/apps/it_manager/equipment_labels.py
T

324 lines
11 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 suggested_inventory_number(pk: int) -> str:
"""Автоинв. № = EAN-13 единицы (удобно для скана и QR)."""
return equipment_ean13(pk)
def _qr_field_map(unit, *, ean13: str = '') -> dict[str, str]:
inv = (getattr(unit, 'inventory_number', None) or '').strip()
model = getattr(unit, 'equipment_model', None)
type_name = ''
if model is not None and getattr(model, 'equipment_type_id', None):
type_name = model.equipment_type.name if model.equipment_type_id else ''
return {
'id': str(unit.pk),
'eq': f'EQ{unit.pk}',
'inventory': inv,
'serial': (unit.serial_number or '').strip(),
'model': str(model) if model else '',
'type': type_name,
'ean13': ean13 or equipment_ean13(unit.pk),
}
def render_qr_pattern(pattern: str, fields: dict[str, str]) -> str:
"""Подстановка плейсхолдеров {inventory} {serial} {model} {type} {id} {eq} {ean13}."""
import re
text = pattern or ''
def repl(match):
key = (match.group(1) or '').strip().lower()
return fields.get(key, '')
return re.sub(r'\{([a-zA-Z0-9_]+)\}', repl, text).strip()
def equipment_qr_payload(unit, pattern: str | None = None, *, ean13: str = '') -> str:
"""Содержимое QR: шаблон из блока или по умолчанию инв. № / EQ{id}."""
fields = _qr_field_map(unit, ean13=ean13)
pattern = (pattern or '').strip()
if pattern:
rendered = render_qr_pattern(pattern, fields)
if rendered:
return rendered
if fields['inventory']:
return fields['inventory']
return fields['eq']
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 ensure_layout_has_text(layout: list | None, *, width_mm: float, height_mm: float) -> list:
"""Если в шаблоне только QR/EAN — подмешать текстовые блоки из дефолта."""
from apps.it_manager.models.label_template import DEFAULT_LABEL_LAYOUT
blocks = [b for b in (layout or []) if isinstance(b, dict)]
present = {(b.get('type') or '').strip() for b in blocks}
if present & TEXT_BLOCK_TYPES:
return blocks
for raw in DEFAULT_LABEL_LAYOUT:
if raw['type'] in TEXT_BLOCK_TYPES and raw['type'] not in present:
block = dict(raw)
# чуть подогнать под текущий размер этикетки
block['x_mm'] = min(float(block['x_mm']), max(0.0, width_mm - float(block['w_mm'])))
block['y_mm'] = min(float(block['y_mm']), max(0.0, height_mm - float(block['h_mm'])))
blocks.append(block)
present.add(raw['type'])
return blocks
def _clamp_block_geom(x, y, w, h, *, width_mm: float, height_mm: float):
w = max(2.0, min(float(w), width_mm))
h = max(2.0, min(float(h), height_mm))
x = max(0.0, min(float(x), max(0.0, width_mm - w)))
y = max(0.0, min(float(y), max(0.0, height_mm - h)))
return x, y, w, h
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()
width_mm = float(template.width_mm or 70)
height_mm = float(template.height_mm or 42)
need_qr = codes in ('qr', 'both')
need_ean = codes in ('ean', 'both')
ean_digits = equipment_ean13(unit.pk)
ean_b64 = ''
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(),
}
default_qr = equipment_qr_payload(unit, ean13=ean_digits)
# общий png только как fallback для поиска; каждый QR-блок рисуется со своим pattern
qr_png = qr_png_base64(default_qr) if need_qr else ''
layout = ensure_layout_has_text(
template.layout, width_mm=width_mm, height_mm=height_mm,
)
blocks_out = []
for raw in layout:
btype = (raw.get('type') or '').strip()
if not _block_visible(btype, codes):
continue
x_mm, y_mm, w_mm, h_mm = _clamp_block_geom(
raw.get('x_mm') or 0,
raw.get('y_mm') or 0,
raw.get('w_mm') or 10,
raw.get('h_mm') or 10,
width_mm=width_mm,
height_mm=height_mm,
)
# текстовым блокам — чуть больше высоты, чтобы pt не обрезался overflow
if btype in TEXT_BLOCK_TYPES:
h_mm = max(h_mm, max(4.0, float(raw.get('font_pt') or 9) * 0.45))
if y_mm + h_mm > height_mm:
y_mm = max(0.0, height_mm - h_mm)
font_pt = float(raw.get('font_pt') or 9)
# CSS mm: точка, не запятая локали (иначе layout «не применяется»)
block = {
'id': raw.get('id') or btype,
'type': btype,
'x_mm': x_mm,
'y_mm': y_mm,
'w_mm': w_mm,
'h_mm': h_mm,
'font_pt': font_pt,
'x_css': f'{x_mm:.2f}',
'y_css': f'{y_mm:.2f}',
'w_css': f'{w_mm:.2f}',
'h_css': f'{h_mm:.2f}',
'font_css': f'{font_pt:.1f}',
'bold': bool(raw.get('bold')),
'align': (raw.get('align') or 'left'),
'text': '',
'png': '',
}
if btype == 'qr':
pattern = (raw.get('text') or '').strip()
payload = equipment_qr_payload(unit, pattern, ean13=ean_digits)
block['png'] = qr_png_base64(payload) if need_qr else ''
block['text'] = payload
elif btype == 'ean13':
block['png'] = ean_b64
block['text'] = ean_digits
else:
block['text'] = _text_for_block(btype, raw, fields)
if btype in TEXT_BLOCK_TYPES and not block['text']:
continue
blocks_out.append(block)
return {
'id': unit.pk,
'model': fields['model'],
'type_name': fields['type_name'],
'serial': fields['serial'],
'inventory': fields['inventory'],
'qr_payload': default_qr,
'qr_png': qr_png,
'ean13': ean_digits,
'ean_png': ean_b64,
'codes': codes,
'width_mm': width_mm,
'height_mm': height_mm,
'width_css': f'{width_mm:.2f}',
'height_css': f'{height_mm:.2f}',
'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 '')[:500]
if btype in ('custom', 'qr')
else ''
),
})
return out