458 lines
15 KiB
Python
458 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""Генератор синтетических прайсов клиник (RU/KZ/EN) + эталонная нормализация + справочник канонов.
|
||
|
||
Выдаёт:
|
||
fixtures/raw/* — 6 форматов «сырых» прайсов (Excel/CSV/txt/PNG/PDF)
|
||
fixtures/normalized/golden.json — эталон: каждая строка → канон (оракул для TDD ETL)
|
||
contracts/service_canon.json — справочник канонических услуг (seed для нормализации)
|
||
Детерминированно (seed=42).
|
||
"""
|
||
|
||
import csv
|
||
import json
|
||
import os
|
||
import random
|
||
import subprocess
|
||
import sys
|
||
|
||
ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
|
||
RAW = os.path.join(ROOT, "fixtures/raw")
|
||
NORM = os.path.join(ROOT, "fixtures/normalized")
|
||
CONTRACTS = os.path.join(ROOT, "contracts")
|
||
for d in (RAW, NORM, CONTRACTS):
|
||
os.makedirs(d, exist_ok=True)
|
||
random.seed(42)
|
||
|
||
|
||
def ensure(pkg, mod=None):
|
||
try:
|
||
__import__(mod or pkg)
|
||
except ImportError:
|
||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
|
||
|
||
|
||
# Справочник канонических медуслуг. lang-тегированные синонимы — материал для нормализации.
|
||
CANONS = [
|
||
{
|
||
"id": "cbc",
|
||
"ru": "Общий анализ крови",
|
||
"kz": "Қанның жалпы анализі",
|
||
"en": "Complete blood count (CBC)",
|
||
"category": "Лаборатория",
|
||
"code": "B03.016.003",
|
||
"unit": "1 иссл.",
|
||
"price": (1200, 4500),
|
||
"syn": [
|
||
("ОАК", "ru"),
|
||
("Общий анализ крови (развернутый)", "ru"),
|
||
("Клинический анализ крови", "ru"),
|
||
("КАК", "ru"),
|
||
("CBC", "en"),
|
||
("Қанның жалпы талдауы", "kz"),
|
||
],
|
||
},
|
||
{
|
||
"id": "uzi_abd",
|
||
"ru": "УЗИ органов брюшной полости",
|
||
"kz": "Құрсақ қуысы ағзаларының УДЗ",
|
||
"en": "Abdominal ultrasound",
|
||
"category": "Диагностика/УЗИ",
|
||
"code": "A04.16.001",
|
||
"unit": "1 иссл.",
|
||
"price": (6000, 13000),
|
||
"syn": [
|
||
("УЗИ ОБП", "ru"),
|
||
("УЗИ брюшной полости", "ru"),
|
||
("Ультразвуковое исследование брюшной полости", "ru"),
|
||
("Құрсақ қуысы УДЗ", "kz"),
|
||
("Abdominal US", "en"),
|
||
],
|
||
},
|
||
{
|
||
"id": "therapist",
|
||
"ru": "Приём врача-терапевта",
|
||
"kz": "Терапевт дәрігердің қабылдауы",
|
||
"en": "General practitioner consultation",
|
||
"category": "Приём специалистов",
|
||
"code": "B01.047.001",
|
||
"unit": "1 приём",
|
||
"price": (4000, 9000),
|
||
"syn": [
|
||
("Прием терапевта", "ru"),
|
||
("Консультация терапевта первичная", "ru"),
|
||
("Терапевт қабылдауы", "kz"),
|
||
("GP visit", "en"),
|
||
],
|
||
},
|
||
{
|
||
"id": "ecg",
|
||
"ru": "Электрокардиография (ЭКГ)",
|
||
"kz": "Электрокардиография (ЭКГ)",
|
||
"en": "Electrocardiography (ECG)",
|
||
"category": "Диагностика",
|
||
"code": "A05.10.006",
|
||
"unit": "1 иссл.",
|
||
"price": (2000, 5000),
|
||
"syn": [
|
||
("ЭКГ", "ru"),
|
||
("ЭКГ с расшифровкой", "ru"),
|
||
("Электрокардиография", "ru"),
|
||
("ECG", "en"),
|
||
("ЭКГ + описание", "ru"),
|
||
],
|
||
},
|
||
{
|
||
"id": "glucose",
|
||
"ru": "Глюкоза крови",
|
||
"kz": "Қандағы глюкоза",
|
||
"en": "Blood glucose",
|
||
"category": "Лаборатория",
|
||
"code": "B03.016.006",
|
||
"unit": "1 иссл.",
|
||
"price": (800, 2200),
|
||
"syn": [
|
||
("Глюкоза", "ru"),
|
||
("Сахар крови", "ru"),
|
||
("Қандағы глюкоза", "kz"),
|
||
("Glucose", "en"),
|
||
],
|
||
},
|
||
{
|
||
"id": "cardiologist",
|
||
"ru": "Консультация кардиолога",
|
||
"kz": "Кардиолог кеңесі",
|
||
"en": "Cardiologist consultation",
|
||
"category": "Приём специалистов",
|
||
"code": "B01.015.001",
|
||
"unit": "1 приём",
|
||
"price": (5000, 12000),
|
||
"syn": [
|
||
("Прием кардиолога", "ru"),
|
||
("Кардиолог консультация первичная", "ru"),
|
||
("Кардиолог қабылдауы", "kz"),
|
||
],
|
||
},
|
||
{
|
||
"id": "mri_brain",
|
||
"ru": "МРТ головного мозга",
|
||
"kz": "Бас миының МРТ",
|
||
"en": "Brain MRI",
|
||
"category": "Диагностика/МРТ",
|
||
"code": "A05.23.009",
|
||
"unit": "1 иссл.",
|
||
"price": (18000, 35000),
|
||
"syn": [
|
||
("МРТ г/м", "ru"),
|
||
("Магнитно-резонансная томография головного мозга", "ru"),
|
||
("Бас миы МРТ", "kz"),
|
||
("Brain MRI", "en"),
|
||
],
|
||
},
|
||
{
|
||
"id": "biochem",
|
||
"ru": "Биохимический анализ крови",
|
||
"kz": "Қанның биохимиялық анализі",
|
||
"en": "Blood chemistry panel",
|
||
"category": "Лаборатория",
|
||
"code": "B03.016.004",
|
||
"unit": "1 иссл.",
|
||
"price": (3500, 9000),
|
||
"syn": [
|
||
("Биохимия крови", "ru"),
|
||
("Б/х крови", "ru"),
|
||
("Биохимический анализ крови (базовый)", "ru"),
|
||
("Қан биохимиясы", "kz"),
|
||
],
|
||
},
|
||
{
|
||
"id": "tooth_ext",
|
||
"ru": "Удаление зуба",
|
||
"kz": "Тісті жұлу",
|
||
"en": "Tooth extraction",
|
||
"category": "Стоматология",
|
||
"code": "A16.07.001",
|
||
"unit": "1 зуб",
|
||
"price": (6000, 15000),
|
||
"syn": [
|
||
("Удаление зуба простое", "ru"),
|
||
("Экстракция зуба", "ru"),
|
||
("Тіс жұлу", "kz"),
|
||
("Tooth removal", "en"),
|
||
],
|
||
},
|
||
{
|
||
"id": "xray_chest",
|
||
"ru": "Рентген грудной клетки",
|
||
"kz": "Кеуде клеткасының рентгені",
|
||
"en": "Chest X-ray",
|
||
"category": "Диагностика",
|
||
"code": "A06.09.007",
|
||
"unit": "1 иссл.",
|
||
"price": (2500, 6000),
|
||
"syn": [
|
||
("Рентгенография ОГК", "ru"),
|
||
("Флюорография", "ru"),
|
||
("Кеуде рентгені", "kz"),
|
||
("Chest X-ray", "en"),
|
||
],
|
||
},
|
||
{
|
||
"id": "tsh",
|
||
"ru": "Тиреотропный гормон (ТТГ)",
|
||
"kz": "Тиреотропты гормон (ТТГ)",
|
||
"en": "Thyroid-stimulating hormone (TSH)",
|
||
"category": "Лаборатория",
|
||
"code": "B03.016.021",
|
||
"unit": "1 иссл.",
|
||
"price": (1500, 4000),
|
||
"syn": [("ТТГ", "ru"), ("Тиреотропный гормон", "ru"), ("TSH", "en"), ("ТТГ гормоны", "kz")],
|
||
},
|
||
{
|
||
"id": "urinalysis",
|
||
"ru": "Общий анализ мочи",
|
||
"kz": "Зәрдің жалпы анализі",
|
||
"en": "Urinalysis",
|
||
"category": "Лаборатория",
|
||
"code": "B03.016.010",
|
||
"unit": "1 иссл.",
|
||
"price": (900, 2500),
|
||
"syn": [
|
||
("ОАМ", "ru"),
|
||
("Общий анализ мочи", "ru"),
|
||
("Зәр анализі", "kz"),
|
||
("Urine test", "en"),
|
||
],
|
||
},
|
||
{
|
||
"id": "gyn",
|
||
"ru": "Приём гинеколога",
|
||
"kz": "Гинеколог қабылдауы",
|
||
"en": "Gynecologist consultation",
|
||
"category": "Приём специалистов",
|
||
"code": "B01.001.001",
|
||
"unit": "1 приём",
|
||
"price": (5000, 11000),
|
||
"syn": [
|
||
("Прием гинеколога", "ru"),
|
||
("Консультация гинеколога первичная", "ru"),
|
||
("Гинеколог кеңесі", "kz"),
|
||
],
|
||
},
|
||
{
|
||
"id": "covid_pcr",
|
||
"ru": "ПЦР на COVID-19",
|
||
"kz": "COVID-19 ПТР",
|
||
"en": "COVID-19 PCR test",
|
||
"category": "Лаборатория",
|
||
"code": "A26.08.027",
|
||
"unit": "1 иссл.",
|
||
"price": (7000, 16000),
|
||
"syn": [
|
||
("ПЦР COVID-19", "ru"),
|
||
("ПЦР-тест на коронавирус", "ru"),
|
||
("COVID PCR", "en"),
|
||
("COVID ПТР", "kz"),
|
||
],
|
||
},
|
||
{
|
||
"id": "vit_d",
|
||
"ru": "Витамин D (25-OH)",
|
||
"kz": "D дәрумені (25-OH)",
|
||
"en": "Vitamin D (25-OH)",
|
||
"category": "Лаборатория",
|
||
"code": "B03.016.115",
|
||
"unit": "1 иссл.",
|
||
"price": (6000, 12000),
|
||
"syn": [
|
||
("25-OH витамин D", "ru"),
|
||
("Витамин Д", "ru"),
|
||
("Vitamin D", "en"),
|
||
("D витамині", "kz"),
|
||
],
|
||
},
|
||
]
|
||
CLINICS = [
|
||
"Клиника «Аль-Шифа»",
|
||
"Медцентр «Сункар»",
|
||
"Diagnostic Lab Astana",
|
||
"Клиника «Береке»",
|
||
"Медцентр «Тенгри»",
|
||
]
|
||
|
||
|
||
def price(c):
|
||
return random.randint(c["price"][0] // 100, c["price"][1] // 100) * 100
|
||
|
||
|
||
def variant(c):
|
||
return random.choice([(c["ru"], "ru")] + c["syn"])
|
||
|
||
|
||
GOLDEN = []
|
||
|
||
|
||
def build(file_name, clinic, n):
|
||
rows = []
|
||
for c in random.sample(CANONS, k=min(n, len(CANONS))):
|
||
raw_name, lang = variant(c)
|
||
p = price(c)
|
||
rows.append({"raw_name": raw_name, "price": p, "unit": c["unit"]})
|
||
GOLDEN.append(
|
||
{
|
||
"source_file": file_name,
|
||
"clinic": clinic,
|
||
"raw_name": raw_name,
|
||
"lang": lang,
|
||
"canon_id": c["id"],
|
||
"canonical_ru": c["ru"],
|
||
"canonical_kz": c["kz"],
|
||
"canonical_en": c["en"],
|
||
"category": c["category"],
|
||
"code": c["code"],
|
||
"price": p,
|
||
"currency": "KZT",
|
||
"unit": c["unit"],
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
ensure("openpyxl")
|
||
from openpyxl import Workbook
|
||
|
||
# 1) чистый Excel
|
||
rows = build("clinic_alpha_clean.xlsx", CLINICS[0], 9)
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "Прайс"
|
||
ws.append(["Наименование услуги", "Цена, тг"])
|
||
for r in rows:
|
||
ws.append([r["raw_name"], r["price"]])
|
||
wb.save(f"{RAW}/clinic_alpha_clean.xlsx")
|
||
|
||
# 2) грязный Excel
|
||
rows = build("clinic_beta_dirty.xlsx", CLINICS[1], 9)
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "PRICE 2026"
|
||
ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"])
|
||
ws.append([])
|
||
ws.append(["№", "Услуга / Қызмет", "Стоимость (тенге)", "Ед."])
|
||
secs = ["— Лаборатория / Зертхана —", "— Диагностика —", "— Приём специалистов —"]
|
||
i = 1
|
||
for k, r in enumerate(rows):
|
||
if k % 3 == 0 and secs:
|
||
ws.append([secs.pop(0), None, None, None])
|
||
val = f"от {r['price']}" if random.random() < 0.3 else r["price"]
|
||
ws.append([i, r["raw_name"], val, r["unit"]])
|
||
i += 1
|
||
wb.save(f"{RAW}/clinic_beta_dirty.xlsx")
|
||
|
||
# 3) CSV ;
|
||
rows = build("clinic_gamma.csv", CLINICS[2], 8)
|
||
with open(f"{RAW}/clinic_gamma.csv", "w", encoding="utf-8", newline="") as f:
|
||
w = csv.writer(f, delimiter=";")
|
||
w.writerow(["service", "price_kzt", "unit"])
|
||
for r in rows:
|
||
w.writerow([r["raw_name"], r["price"], r["unit"]])
|
||
|
||
# 4) грязный текст
|
||
rows = build("clinic_delta_messy.txt", CLINICS[4], 7)
|
||
with open(f"{RAW}/clinic_delta_messy.txt", "w", encoding="utf-8") as f:
|
||
f.write("МЕДЦЕНТР «ТЕНГРИ» — действующие цены\n\n")
|
||
for r in rows:
|
||
sep = random.choice([" — ", " ... ", ": ", " "])
|
||
cur = random.choice(["тг", "тенге", "₸", "KZT"])
|
||
f.write(f"{r['raw_name']}{sep}{r['price']} {cur} ({r['unit']})\n")
|
||
f.write("\n* Цены справочные, уточняйте в регистратуре. Бағалар анықтамалық.\n")
|
||
|
||
# 5) PNG-скан
|
||
rows = build("clinic_epsilon_scan.png", CLINICS[3], 9)
|
||
ensure("Pillow", "PIL")
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
|
||
def font(sz):
|
||
for p in [
|
||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||
"/Library/Fonts/Arial.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||
]:
|
||
if os.path.exists(p):
|
||
return ImageFont.truetype(p, sz)
|
||
return ImageFont.load_default()
|
||
|
||
|
||
img = Image.new("RGB", (900, 720), (250, 248, 244))
|
||
d = ImageDraw.Draw(img)
|
||
d.text((40, 30), "Клиника «Береке» — прайс-лист", fill=(20, 20, 20), font=font(34))
|
||
d.line((40, 85, 860, 85), fill=(180, 180, 180), width=2)
|
||
y = 110
|
||
for r in rows:
|
||
d.text((50, y), r["raw_name"][:42], fill=(30, 30, 30), font=font(23))
|
||
d.text((720, y), f"{r['price']} тг", fill=(30, 30, 30), font=font(23))
|
||
y += 62
|
||
img = img.rotate(-1.4, expand=False, fillcolor=(250, 248, 244))
|
||
img.save(f"{RAW}/clinic_epsilon_scan.png")
|
||
|
||
# 6) PDF
|
||
rows = build("clinic_zeta_prices.pdf", CLINICS[2], 10)
|
||
pdf = "skip"
|
||
try:
|
||
ensure("reportlab")
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
from reportlab.pdfgen import canvas
|
||
|
||
fp = next(
|
||
(
|
||
p
|
||
for p in [
|
||
"/Library/Fonts/Arial.ttf",
|
||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||
]
|
||
if os.path.exists(p)
|
||
),
|
||
None,
|
||
)
|
||
if fp:
|
||
pdfmetrics.registerFont(TTFont("U", fp))
|
||
c = canvas.Canvas(f"{RAW}/clinic_zeta_prices.pdf", pagesize=A4)
|
||
c.setFont("U", 18)
|
||
c.drawString(60, 790, "Diagnostic Lab Astana — Price list / Прайс")
|
||
c.setFont("U", 12)
|
||
y = 750
|
||
for r in rows:
|
||
c.drawString(60, y, r["raw_name"][:55])
|
||
c.drawRightString(540, y, f"{r['price']} KZT")
|
||
y -= 28
|
||
c.save()
|
||
pdf = "ok"
|
||
except Exception as e:
|
||
pdf = f"skip ({e})"
|
||
|
||
# эталон нормализации + справочник
|
||
with open(f"{NORM}/golden.json", "w", encoding="utf-8") as f:
|
||
json.dump(GOLDEN, f, ensure_ascii=False, indent=2)
|
||
canon_out = [
|
||
{
|
||
"canon_id": c["id"],
|
||
"canonical_ru": c["ru"],
|
||
"canonical_kz": c["kz"],
|
||
"canonical_en": c["en"],
|
||
"category": c["category"],
|
||
"code": c["code"],
|
||
"unit": c["unit"],
|
||
"synonyms": [{"text": t, "lang": l} for t, l in c["syn"]],
|
||
}
|
||
for c in CANONS
|
||
]
|
||
with open(f"{CONTRACTS}/service_canon.json", "w", encoding="utf-8") as f:
|
||
json.dump(canon_out, f, ensure_ascii=False, indent=2)
|
||
|
||
print("PDF:", pdf)
|
||
print("raw:", sorted(os.listdir(RAW)))
|
||
print("golden rows:", len(GOLDEN), "| canons:", len(CANONS))
|