feat: Add KRS Audit panel with full PDF parsing

- New admin panel /admin/krs-audit for KRS data extraction
- Full PDF parser extracting: company data, capital, shares, PKD codes,
  management board, shareholders, procurators, financial reports
- API endpoints for single/batch audits and PDF download
- Company profile shows "Odpis PDF" button and last audit date
- Database migration for krs_audits, company_pkd, company_financial_reports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-13 16:46:54 +01:00
parent 11c49ad937
commit de52e6991c
6 changed files with 2376 additions and 4 deletions

527
app.py
View File

@ -22,8 +22,8 @@ import secrets
import re
import json
from collections import deque
from datetime import datetime, timedelta
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session, Response
from datetime import datetime, timedelta, date
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session, Response, send_file
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from flask_wtf.csrf import CSRFProtect
from flask_limiter import Limiter
@ -109,7 +109,10 @@ from database import (
Person,
CompanyPerson,
GBPAudit,
ITAudit
ITAudit,
KRSAudit,
CompanyPKD,
CompanyFinancialReport
)
# Import services
@ -155,6 +158,16 @@ except ImportError as e:
GBP_AUDIT_VERSION = None
logger.warning(f"GBP audit service not available: {e}")
# KRS (Krajowy Rejestr Sądowy) audit service
try:
from krs_audit_service import parse_krs_pdf, parse_krs_pdf_full
KRS_AUDIT_AVAILABLE = True
KRS_AUDIT_VERSION = '1.0'
except ImportError as e:
KRS_AUDIT_AVAILABLE = False
KRS_AUDIT_VERSION = None
logger.warning(f"KRS audit service not available: {e}")
# Initialize Flask app
app = Flask(__name__)
@ -8566,6 +8579,514 @@ def api_zopk_search_news():
db.close()
# ============================================================
# KRS AUDIT (Krajowy Rejestr Sądowy)
# ============================================================
@app.route('/admin/krs-audit')
@login_required
def admin_krs_audit():
"""
Admin dashboard for KRS (Krajowy Rejestr Sądowy) audit.
Displays:
- Summary stats (with KRS, audited count, data extraction status)
- List of companies with KRS numbers
- Audit progress and status for each company
- Links to source PDF files
"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
db = SessionLocal()
try:
from sqlalchemy import func
# Get all active companies with KRS numbers
companies_query = db.query(Company).filter(
Company.status == 'active',
Company.krs.isnot(None),
Company.krs != ''
).order_by(Company.name).all()
# Get latest audit for each company
companies = []
for company in companies_query:
# Get latest audit
latest_audit = db.query(KRSAudit).filter(
KRSAudit.company_id == company.id
).order_by(KRSAudit.audit_date.desc()).first()
# Get PKD codes count
pkd_count = db.query(CompanyPKD).filter(
CompanyPKD.company_id == company.id
).count()
# Get people count
people_count = db.query(CompanyPerson).filter(
CompanyPerson.company_id == company.id
).count()
companies.append({
'id': company.id,
'name': company.name,
'slug': company.slug,
'krs': company.krs,
'nip': company.nip,
'capital_amount': company.capital_amount,
'krs_last_audit_at': company.krs_last_audit_at,
'krs_pdf_path': company.krs_pdf_path,
'audit': latest_audit,
'pkd_count': pkd_count,
'people_count': people_count,
'capital_shares_count': company.capital_shares_count
})
# Calculate stats
total_with_krs = len(companies)
audited_count = len([c for c in companies if c['krs_last_audit_at']])
not_audited_count = total_with_krs - audited_count
with_capital = len([c for c in companies if c['capital_amount']])
with_people = len([c for c in companies if c['people_count'] > 0])
with_pkd = len([c for c in companies if c['pkd_count'] > 0])
# Companies without KRS
no_krs_count = db.query(Company).filter(
Company.status == 'active',
(Company.krs.is_(None)) | (Company.krs == '')
).count()
stats = {
'total_with_krs': total_with_krs,
'audited_count': audited_count,
'not_audited_count': not_audited_count,
'no_krs_count': no_krs_count,
'with_capital': with_capital,
'with_people': with_people,
'with_pkd': with_pkd
}
return render_template('admin/krs_audit_dashboard.html',
companies=companies,
stats=stats,
krs_audit_available=KRS_AUDIT_AVAILABLE,
now=datetime.now()
)
finally:
db.close()
@app.route('/api/krs/audit', methods=['POST'])
@login_required
@limiter.limit("20 per hour")
def api_krs_audit_trigger():
"""
API: Trigger KRS audit for a company (admin-only).
Parses KRS PDF file and extracts all available data:
- Basic info (KRS, NIP, REGON, name, legal form)
- Capital and shares
- Management board, shareholders, procurators
- PKD codes
- Financial reports
Request JSON body:
- company_id: Company ID (integer)
Returns:
- Success: Audit results saved to database
- Error: Error message with status code
"""
if not current_user.is_admin:
return jsonify({
'success': False,
'error': 'Brak uprawnień. Tylko administrator może uruchamiać audyty KRS.'
}), 403
if not KRS_AUDIT_AVAILABLE:
return jsonify({
'success': False,
'error': 'Usługa audytu KRS jest niedostępna.'
}), 503
data = request.get_json()
if not data or not data.get('company_id'):
return jsonify({
'success': False,
'error': 'Podaj company_id firmy do audytu.'
}), 400
company_id = data['company_id']
db = SessionLocal()
try:
company = db.query(Company).filter_by(id=company_id, status='active').first()
if not company:
return jsonify({
'success': False,
'error': 'Firma nie znaleziona.'
}), 404
if not company.krs:
return jsonify({
'success': False,
'error': f'Firma "{company.name}" nie ma numeru KRS.'
}), 400
# Find PDF file
pdf_dir = Path('data/krs_pdfs')
pdf_files = list(pdf_dir.glob(f'*{company.krs}*.pdf'))
if not pdf_files:
return jsonify({
'success': False,
'error': f'Nie znaleziono pliku PDF dla KRS {company.krs}. '
f'Pobierz odpis z ekrs.ms.gov.pl i umieść w data/krs_pdfs/'
}), 404
pdf_path = pdf_files[0]
# Create audit record
audit = KRSAudit(
company_id=company.id,
status='parsing',
progress_percent=10,
progress_message='Parsowanie pliku PDF...',
pdf_filename=pdf_path.name,
pdf_path=str(pdf_path)
)
db.add(audit)
db.commit()
# Parse PDF
try:
parsed_data = parse_krs_pdf(str(pdf_path), verbose=True)
# Update audit with parsed data
audit.status = 'completed'
audit.progress_percent = 100
audit.progress_message = 'Audyt zakończony pomyślnie'
audit.extracted_krs = parsed_data.get('krs')
audit.extracted_nazwa = parsed_data.get('nazwa')
audit.extracted_nip = parsed_data.get('nip')
audit.extracted_regon = parsed_data.get('regon')
audit.extracted_forma_prawna = parsed_data.get('forma_prawna')
audit.extracted_data_rejestracji = parse_date_str(parsed_data.get('data_rejestracji'))
audit.extracted_kapital_zakladowy = parsed_data.get('kapital_zakladowy')
audit.extracted_liczba_udzialow = parsed_data.get('liczba_udzialow')
audit.extracted_sposob_reprezentacji = parsed_data.get('sposob_reprezentacji')
audit.zarzad_count = len(parsed_data.get('zarzad', []))
audit.wspolnicy_count = len(parsed_data.get('wspolnicy', []))
audit.prokurenci_count = len(parsed_data.get('prokurenci', []))
audit.pkd_count = 1 if parsed_data.get('pkd_przewazajacy') else 0
audit.pkd_count += len(parsed_data.get('pkd_pozostale', []))
audit.parsed_data = parsed_data
audit.pdf_downloaded_at = datetime.now()
# Update company with parsed data
if parsed_data.get('kapital_zakladowy'):
company.capital_amount = parsed_data['kapital_zakladowy']
if parsed_data.get('liczba_udzialow'):
company.capital_shares_count = parsed_data['liczba_udzialow']
if parsed_data.get('wartosc_nominalna_udzialu'):
company.capital_share_value = parsed_data['wartosc_nominalna_udzialu']
if parsed_data.get('data_rejestracji'):
company.krs_registration_date = parse_date_str(parsed_data['data_rejestracji'])
if parsed_data.get('sposob_reprezentacji'):
company.krs_representation_rules = parsed_data['sposob_reprezentacji']
if parsed_data.get('czas_trwania'):
company.krs_duration = parsed_data['czas_trwania']
company.krs_last_audit_at = datetime.now()
company.krs_pdf_path = str(pdf_path)
# Import PKD codes
pkd_main = parsed_data.get('pkd_przewazajacy')
if pkd_main:
existing = db.query(CompanyPKD).filter_by(
company_id=company.id,
pkd_code=pkd_main['kod']
).first()
if not existing:
db.add(CompanyPKD(
company_id=company.id,
pkd_code=pkd_main['kod'],
pkd_description=pkd_main['opis'],
is_primary=True,
source='ekrs'
))
# Also update Company.pkd_code
company.pkd_code = pkd_main['kod']
company.pkd_description = pkd_main['opis']
for pkd in parsed_data.get('pkd_pozostale', []):
existing = db.query(CompanyPKD).filter_by(
company_id=company.id,
pkd_code=pkd['kod']
).first()
if not existing:
db.add(CompanyPKD(
company_id=company.id,
pkd_code=pkd['kod'],
pkd_description=pkd['opis'],
is_primary=False,
source='ekrs'
))
# Import people (zarząd, wspólnicy)
for person_data in parsed_data.get('zarzad', []):
_import_krs_person(db, company.id, person_data, 'zarzad', pdf_path.name)
for person_data in parsed_data.get('wspolnicy', []):
_import_krs_person(db, company.id, person_data, 'wspolnik', pdf_path.name)
for person_data in parsed_data.get('prokurenci', []):
_import_krs_person(db, company.id, person_data, 'prokurent', pdf_path.name)
# Import financial reports
for report in parsed_data.get('sprawozdania_finansowe', []):
existing = db.query(CompanyFinancialReport).filter_by(
company_id=company.id,
period_start=parse_date_str(report.get('okres_od')),
period_end=parse_date_str(report.get('okres_do'))
).first()
if not existing:
db.add(CompanyFinancialReport(
company_id=company.id,
period_start=parse_date_str(report.get('okres_od')),
period_end=parse_date_str(report.get('okres_do')),
filed_at=parse_date_str(report.get('data_zlozenia')),
source='ekrs'
))
db.commit()
logger.info(f"KRS audit completed for {company.name} (KRS: {company.krs})")
return jsonify({
'success': True,
'message': f'Audyt KRS zakończony dla {company.name}',
'company_id': company.id,
'data': {
'krs': parsed_data.get('krs'),
'nazwa': parsed_data.get('nazwa'),
'kapital': float(parsed_data.get('kapital_zakladowy', 0) or 0),
'zarzad_count': len(parsed_data.get('zarzad', [])),
'wspolnicy_count': len(parsed_data.get('wspolnicy', [])),
'pkd_count': audit.pkd_count
}
})
except Exception as e:
audit.status = 'error'
audit.progress_percent = 0
audit.error_message = str(e)
db.commit()
logger.error(f"KRS audit failed for {company.name}: {e}")
return jsonify({
'success': False,
'error': f'Błąd parsowania PDF: {str(e)}'
}), 500
finally:
db.close()
def parse_date_str(date_val):
"""Helper to parse date from string or return date object as-is"""
if date_val is None:
return None
if isinstance(date_val, date):
return date_val
if isinstance(date_val, str):
try:
return datetime.strptime(date_val, '%Y-%m-%d').date()
except:
return None
return None
def _import_krs_person(db, company_id, person_data, role_category, source_document):
"""Helper to import a person from KRS data"""
pesel = person_data.get('pesel')
nazwisko = person_data.get('nazwisko', '')
imiona = person_data.get('imiona', '')
rola = person_data.get('rola', '')
# Find or create Person
person = None
if pesel:
person = db.query(Person).filter_by(pesel=pesel).first()
if not person:
# Try to find by name
person = db.query(Person).filter_by(
nazwisko=nazwisko,
imiona=imiona
).first()
if not person:
person = Person(
pesel=pesel,
nazwisko=nazwisko,
imiona=imiona
)
db.add(person)
db.flush()
# Check if relation already exists
existing_rel = db.query(CompanyPerson).filter_by(
company_id=company_id,
person_id=person.id,
role_category=role_category
).first()
if not existing_rel:
cp = CompanyPerson(
company_id=company_id,
person_id=person.id,
role=rola,
role_category=role_category,
source='ekrs.ms.gov.pl',
source_document=source_document,
fetched_at=datetime.now()
)
# Add shares info for shareholders
if role_category == 'wspolnik':
cp.shares_count = person_data.get('udzialy_liczba')
if person_data.get('udzialy_wartosc'):
cp.shares_value = person_data['udzialy_wartosc']
if person_data.get('udzialy_procent'):
cp.shares_percent = person_data['udzialy_procent']
db.add(cp)
@app.route('/api/krs/audit/batch', methods=['POST'])
@login_required
@limiter.limit("5 per hour")
def api_krs_audit_batch():
"""
API: Trigger batch KRS audit for all companies with KRS numbers.
This runs audits sequentially to avoid overloading the system.
Returns progress updates via the response.
"""
if not current_user.is_admin:
return jsonify({
'success': False,
'error': 'Brak uprawnień.'
}), 403
if not KRS_AUDIT_AVAILABLE:
return jsonify({
'success': False,
'error': 'Usługa audytu KRS jest niedostępna.'
}), 503
db = SessionLocal()
try:
# Get companies with KRS that haven't been audited recently
companies = db.query(Company).filter(
Company.status == 'active',
Company.krs.isnot(None),
Company.krs != ''
).order_by(Company.name).all()
results = {
'total': len(companies),
'success': 0,
'failed': 0,
'skipped': 0,
'details': []
}
pdf_dir = Path('data/krs_pdfs')
for company in companies:
# Find PDF file
pdf_files = list(pdf_dir.glob(f'*{company.krs}*.pdf'))
if not pdf_files:
results['skipped'] += 1
results['details'].append({
'company': company.name,
'krs': company.krs,
'status': 'skipped',
'reason': 'Brak pliku PDF'
})
continue
pdf_path = pdf_files[0]
try:
parsed_data = parse_krs_pdf(str(pdf_path))
# Update company
if parsed_data.get('kapital_zakladowy'):
company.capital_amount = parsed_data['kapital_zakladowy']
if parsed_data.get('liczba_udzialow'):
company.capital_shares_count = parsed_data['liczba_udzialow']
company.krs_last_audit_at = datetime.now()
company.krs_pdf_path = str(pdf_path)
results['success'] += 1
results['details'].append({
'company': company.name,
'krs': company.krs,
'status': 'success'
})
except Exception as e:
results['failed'] += 1
results['details'].append({
'company': company.name,
'krs': company.krs,
'status': 'error',
'reason': str(e)
})
db.commit()
return jsonify({
'success': True,
'message': f'Audyt zakończony: {results["success"]} sukces, '
f'{results["failed"]} błędów, {results["skipped"]} pominiętych',
'results': results
})
finally:
db.close()
@app.route('/api/krs/pdf/<int:company_id>')
@login_required
def api_krs_pdf_download(company_id):
"""
API: Download/serve KRS PDF file for a company.
"""
db = SessionLocal()
try:
company = db.query(Company).filter_by(id=company_id).first()
if not company:
return jsonify({'error': 'Firma nie znaleziona'}), 404
if not company.krs_pdf_path:
return jsonify({'error': 'Brak pliku PDF'}), 404
pdf_path = Path(company.krs_pdf_path)
if not pdf_path.exists():
return jsonify({'error': 'Plik PDF nie istnieje'}), 404
return send_file(
str(pdf_path),
mimetype='application/pdf',
as_attachment=False,
download_name=pdf_path.name
)
finally:
db.close()
# ============================================================
# ERROR HANDLERS
# ============================================================

View File

@ -334,6 +334,18 @@ class Company(Base):
website_status = Column(String(20)) # 'active', 'broken', 'no_website'
website_quality_score = Column(Integer) # 0-100
# === KRS DATA (added 2026-01-13) ===
krs_registration_date = Column(Date) # Data wpisu do KRS
krs_company_agreement_date = Column(Date) # Data umowy spółki
krs_duration = Column(String(100)) # Czas trwania (NIEOZNACZONY lub data)
krs_representation_rules = Column(Text) # Sposób reprezentacji
capital_currency = Column(String(3), default='PLN')
capital_shares_count = Column(Integer) # Liczba udziałów
capital_share_value = Column(Numeric(15, 2)) # Wartość nominalna udziału
is_opp = Column(Boolean, default=False) # Czy OPP
krs_last_audit_at = Column(DateTime) # Data ostatniego audytu KRS
krs_pdf_path = Column(Text) # Ścieżka do pliku PDF
# Relationships
category = relationship('Category', back_populates='companies')
services = relationship('CompanyService', back_populates='company', cascade='all, delete-orphan')
@ -2202,6 +2214,141 @@ class CompanyPerson(Base):
return f"<CompanyPerson {self.person.full_name()} - {self.role} @ {self.company.name}>"
# ============================================================
# KRS AUDIT
# ============================================================
class KRSAudit(Base):
"""
KRS audit history - tracks PDF downloads and data extraction.
Each audit represents one extraction run from EKRS.
"""
__tablename__ = 'krs_audits'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Audit timing
audit_date = Column(DateTime, default=datetime.now, nullable=False, index=True)
# PDF source info
pdf_filename = Column(String(255)) # np. "odpis_pelny_0000882964.pdf"
pdf_path = Column(Text) # full path to stored PDF
pdf_downloaded_at = Column(DateTime)
# Extraction status
status = Column(String(20), default='pending', index=True) # pending, downloading, parsing, completed, error
progress_percent = Column(Integer, default=0)
progress_message = Column(Text)
error_message = Column(Text)
# Extracted data summary
extracted_krs = Column(String(10))
extracted_nazwa = Column(Text)
extracted_nip = Column(String(10))
extracted_regon = Column(String(14))
extracted_forma_prawna = Column(String(255))
extracted_data_rejestracji = Column(Date)
extracted_kapital_zakladowy = Column(Numeric(15, 2))
extracted_waluta = Column(String(3), default='PLN')
extracted_liczba_udzialow = Column(Integer)
extracted_sposob_reprezentacji = Column(Text)
# Counts for quick stats
zarzad_count = Column(Integer, default=0)
wspolnicy_count = Column(Integer, default=0)
prokurenci_count = Column(Integer, default=0)
pkd_count = Column(Integer, default=0)
# Full parsed data as JSON
parsed_data = Column(JSONB)
# Audit metadata
audit_version = Column(String(20), default='1.0')
audit_source = Column(String(50), default='ekrs.ms.gov.pl')
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = relationship('Company', backref='krs_audits')
def __repr__(self):
return f'<KRSAudit company_id={self.company_id} status={self.status}>'
@property
def status_label(self):
"""Human-readable status label in Polish"""
labels = {
'pending': 'Oczekuje',
'downloading': 'Pobieranie PDF',
'parsing': 'Przetwarzanie',
'completed': 'Ukończony',
'error': 'Błąd'
}
return labels.get(self.status, self.status)
class CompanyPKD(Base):
"""
PKD codes for companies (Polska Klasyfikacja Działalności).
Multiple PKD codes per company allowed - one is marked as primary.
"""
__tablename__ = 'company_pkd'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
pkd_code = Column(String(10), nullable=False, index=True) # np. "62.03.Z"
pkd_description = Column(Text)
is_primary = Column(Boolean, default=False) # przeważający PKD
source = Column(String(50), default='ekrs') # ekrs, ceidg
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = relationship('Company', backref='pkd_codes')
__table_args__ = (
UniqueConstraint('company_id', 'pkd_code', name='uq_company_pkd'),
)
def __repr__(self):
primary = ' (główny)' if self.is_primary else ''
return f'<CompanyPKD {self.pkd_code}{primary}>'
class CompanyFinancialReport(Base):
"""
Financial reports (sprawozdania finansowe) filed with KRS.
Tracks report periods and filing dates.
"""
__tablename__ = 'company_financial_reports'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
period_start = Column(Date)
period_end = Column(Date)
filed_at = Column(Date)
report_type = Column(String(50), default='annual') # annual, quarterly
source = Column(String(50), default='ekrs')
# Timestamps
created_at = Column(DateTime, default=datetime.now)
# Relationship
company = relationship('Company', backref='financial_reports')
__table_args__ = (
UniqueConstraint('company_id', 'period_start', 'period_end', 'report_type', name='uq_company_financial_report'),
)
def __repr__(self):
return f'<CompanyFinancialReport {self.period_start} - {self.period_end}>'
# ============================================================
# DATABASE INITIALIZATION
# ============================================================

View File

@ -0,0 +1,136 @@
-- ============================================================
-- Migration: 013_krs_audit.sql
-- Date: 2026-01-13
-- Description: Add KRS Audit table for tracking KRS data extracts
-- ============================================================
-- KRS Audit table - tracks audit history and PDF sources
CREATE TABLE IF NOT EXISTS krs_audits (
id SERIAL PRIMARY KEY,
company_id INTEGER REFERENCES companies(id) ON DELETE CASCADE NOT NULL,
-- Audit timing
audit_date TIMESTAMP DEFAULT NOW() NOT NULL,
-- PDF source info
pdf_filename VARCHAR(255), -- np. "odpis_pelny_0000882964.pdf"
pdf_path TEXT, -- full path to stored PDF
pdf_downloaded_at TIMESTAMP, -- when PDF was fetched from EKRS
-- Extraction status
status VARCHAR(20) DEFAULT 'pending', -- pending, downloading, parsing, completed, error
progress_percent INTEGER DEFAULT 0,
progress_message TEXT,
error_message TEXT,
-- Extracted data summary (for quick display)
extracted_krs VARCHAR(10),
extracted_nazwa TEXT,
extracted_nip VARCHAR(10),
extracted_regon VARCHAR(14),
extracted_forma_prawna VARCHAR(255),
extracted_data_rejestracji DATE,
extracted_kapital_zakladowy NUMERIC(15, 2),
extracted_waluta VARCHAR(3) DEFAULT 'PLN',
extracted_liczba_udzialow INTEGER,
extracted_sposob_reprezentacji TEXT,
-- Counts for quick stats
zarzad_count INTEGER DEFAULT 0,
wspolnicy_count INTEGER DEFAULT 0,
prokurenci_count INTEGER DEFAULT 0,
pkd_count INTEGER DEFAULT 0,
-- Full parsed data as JSON (for reference)
parsed_data JSONB,
-- Audit metadata
audit_version VARCHAR(20) DEFAULT '1.0',
audit_source VARCHAR(50) DEFAULT 'ekrs.ms.gov.pl',
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_krs_audits_company_id ON krs_audits(company_id);
CREATE INDEX IF NOT EXISTS idx_krs_audits_audit_date ON krs_audits(audit_date DESC);
CREATE INDEX IF NOT EXISTS idx_krs_audits_status ON krs_audits(status);
-- Company table extensions for KRS data
ALTER TABLE companies ADD COLUMN IF NOT EXISTS krs_registration_date DATE;
ALTER TABLE companies ADD COLUMN IF NOT EXISTS krs_company_agreement_date DATE;
ALTER TABLE companies ADD COLUMN IF NOT EXISTS krs_duration VARCHAR(100);
ALTER TABLE companies ADD COLUMN IF NOT EXISTS krs_representation_rules TEXT;
ALTER TABLE companies ADD COLUMN IF NOT EXISTS capital_currency VARCHAR(3) DEFAULT 'PLN';
ALTER TABLE companies ADD COLUMN IF NOT EXISTS capital_shares_count INTEGER;
ALTER TABLE companies ADD COLUMN IF NOT EXISTS capital_share_value NUMERIC(15, 2);
ALTER TABLE companies ADD COLUMN IF NOT EXISTS is_opp BOOLEAN DEFAULT FALSE;
ALTER TABLE companies ADD COLUMN IF NOT EXISTS krs_last_audit_at TIMESTAMP;
ALTER TABLE companies ADD COLUMN IF NOT EXISTS krs_pdf_path TEXT;
-- PKD codes table (multiple PKD codes per company)
CREATE TABLE IF NOT EXISTS company_pkd (
id SERIAL PRIMARY KEY,
company_id INTEGER REFERENCES companies(id) ON DELETE CASCADE NOT NULL,
pkd_code VARCHAR(10) NOT NULL, -- np. "62.03.Z"
pkd_description TEXT,
is_primary BOOLEAN DEFAULT FALSE, -- przeważający PKD
source VARCHAR(50) DEFAULT 'ekrs', -- ekrs, ceidg
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(company_id, pkd_code)
);
CREATE INDEX IF NOT EXISTS idx_company_pkd_company_id ON company_pkd(company_id);
CREATE INDEX IF NOT EXISTS idx_company_pkd_code ON company_pkd(pkd_code);
CREATE INDEX IF NOT EXISTS idx_company_pkd_primary ON company_pkd(company_id, is_primary);
-- Financial reports table (sprawozdania finansowe)
CREATE TABLE IF NOT EXISTS company_financial_reports (
id SERIAL PRIMARY KEY,
company_id INTEGER REFERENCES companies(id) ON DELETE CASCADE NOT NULL,
period_start DATE,
period_end DATE,
filed_at DATE,
report_type VARCHAR(50) DEFAULT 'annual', -- annual, quarterly
source VARCHAR(50) DEFAULT 'ekrs',
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(company_id, period_start, period_end, report_type)
);
CREATE INDEX IF NOT EXISTS idx_company_financial_reports_company_id ON company_financial_reports(company_id);
-- Add comments
COMMENT ON TABLE krs_audits IS 'KRS audit history - tracks PDF downloads and data extraction';
COMMENT ON COLUMN krs_audits.pdf_path IS 'Full path to stored PDF file on server';
COMMENT ON COLUMN krs_audits.status IS 'Audit status: pending, downloading, parsing, completed, error';
COMMENT ON COLUMN krs_audits.parsed_data IS 'Full extracted data in JSON format';
COMMENT ON TABLE company_pkd IS 'PKD codes for companies - multiple codes per company allowed';
COMMENT ON COLUMN company_pkd.is_primary IS 'TRUE for main business activity (PKD przeważający)';
COMMENT ON TABLE company_financial_reports IS 'Financial reports (sprawozdania finansowe) filed with KRS';
COMMENT ON COLUMN companies.krs_registration_date IS 'Date of first entry in KRS registry';
COMMENT ON COLUMN companies.krs_representation_rules IS 'Rules for company representation from KRS';
COMMENT ON COLUMN companies.krs_last_audit_at IS 'Date of last KRS audit';
COMMENT ON COLUMN companies.krs_pdf_path IS 'Path to latest KRS PDF file';
-- Grant permissions
GRANT ALL ON TABLE krs_audits TO nordabiz_app;
GRANT ALL ON TABLE company_pkd TO nordabiz_app;
GRANT ALL ON TABLE company_financial_reports TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE krs_audits_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE company_pkd_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE company_financial_reports_id_seq TO nordabiz_app;
-- ============================================================
-- Verification query (run after migration):
-- SELECT c.name, ka.audit_date, ka.status, ka.extracted_kapital_zakladowy
-- FROM companies c
-- LEFT JOIN krs_audits ka ON c.id = ka.company_id
-- WHERE c.krs IS NOT NULL
-- LIMIT 10;
-- ============================================================

685
krs_audit_service.py Normal file
View File

@ -0,0 +1,685 @@
#!/usr/bin/env python3
"""
KRS Audit Service - Full data extraction from KRS PDF files.
Downloads PDF documents from EKRS and extracts complete company data:
- Basic info: KRS, NIP, REGON, company name, legal form
- Address: full address with email, website
- Capital: share capital amount, shares count, nominal value
- People: management board, shareholders, procurators
- PKD codes: main and secondary business activities
- Financial reports: filing dates
- Representation rules
Author: Norda Biznes Development Team
Created: 2026-01-13
"""
import re
import os
import json
import logging
from pathlib import Path
from datetime import datetime, date
from dataclasses import dataclass, field, asdict
from typing import List, Optional, Dict, Any, Tuple
from decimal import Decimal
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
try:
import pdfplumber
except ImportError:
logger.error("Required library pdfplumber. Install: pip install pdfplumber")
raise
# ============================================================
# DATA CLASSES
# ============================================================
@dataclass
class KRSPerson:
"""Person from KRS (board member, shareholder, procurator)"""
nazwisko: str
imiona: str
pesel: Optional[str] = None
rola: str = "" # PREZES ZARZĄDU, CZŁONEK ZARZĄDU, WSPÓLNIK, PROKURENT
rola_kategoria: str = "" # zarzad, wspolnik, prokurent
# For shareholders
udzialy_liczba: Optional[int] = None
udzialy_wartosc: Optional[Decimal] = None
udzialy_procent: Optional[Decimal] = None
def full_name(self) -> str:
return f"{self.imiona} {self.nazwisko}"
@dataclass
class KRSPKD:
"""PKD code from KRS"""
kod: str # e.g., "62.03.Z"
opis: str
jest_przewazajacy: bool = False
@dataclass
class KRSFinancialReport:
"""Financial report filing info from KRS"""
okres_od: Optional[date] = None
okres_do: Optional[date] = None
data_zlozenia: Optional[date] = None
@dataclass
class KRSAddress:
"""Company address from KRS"""
ulica: Optional[str] = None
numer_domu: Optional[str] = None
numer_lokalu: Optional[str] = None
miejscowosc: Optional[str] = None
kod_pocztowy: Optional[str] = None
poczta: Optional[str] = None
wojewodztwo: Optional[str] = None
powiat: Optional[str] = None
gmina: Optional[str] = None
email: Optional[str] = None
www: Optional[str] = None
@dataclass
class KRSFullData:
"""Complete data extracted from KRS PDF"""
# Identifiers
krs: str
nip: Optional[str] = None
regon: Optional[str] = None
# Basic info
nazwa: str = ""
nazwa_skrocona: Optional[str] = None
forma_prawna: Optional[str] = None
# Dates
data_rejestracji: Optional[date] = None
data_umowy_spolki: Optional[date] = None
czas_trwania: Optional[str] = None # "NIEOZNACZONY" or date
# Address
siedziba: Optional[KRSAddress] = None
# Capital
kapital_zakladowy: Optional[Decimal] = None
waluta: str = "PLN"
liczba_udzialow: Optional[int] = None
wartosc_nominalna_udzialu: Optional[Decimal] = None
# Representation
sposob_reprezentacji: Optional[str] = None
# PKD codes
pkd_przewazajacy: Optional[KRSPKD] = None
pkd_pozostale: List[KRSPKD] = field(default_factory=list)
# People
zarzad: List[KRSPerson] = field(default_factory=list)
wspolnicy: List[KRSPerson] = field(default_factory=list)
prokurenci: List[KRSPerson] = field(default_factory=list)
rada_nadzorcza: List[KRSPerson] = field(default_factory=list)
# Status
czy_opp: bool = False
zaleglosci: Optional[str] = None
wierzytelnosci: Optional[str] = None
# Financial reports
sprawozdania_finansowe: List[KRSFinancialReport] = field(default_factory=list)
# Metadata
zrodlo: str = "ekrs.ms.gov.pl"
data_pobrania: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
def convert_value(v):
if isinstance(v, (date, datetime)):
return v.isoformat()
if isinstance(v, Decimal):
return float(v)
if hasattr(v, '__dict__'):
return {k: convert_value(val) for k, val in asdict(v).items()}
if isinstance(v, list):
return [convert_value(item) for item in v]
return v
return {k: convert_value(v) for k, v in asdict(self).items()}
# ============================================================
# PDF PARSING FUNCTIONS
# ============================================================
def extract_text_from_pdf(pdf_path: str) -> str:
"""Extract full text from PDF"""
with pdfplumber.open(pdf_path) as pdf:
text = ""
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
return text
def parse_date(date_str: str) -> Optional[date]:
"""Parse date from various formats"""
if not date_str:
return None
# Try DD.MM.YYYY format
match = re.search(r'(\d{2})\.(\d{2})\.(\d{4})', date_str)
if match:
try:
return date(int(match.group(3)), int(match.group(2)), int(match.group(1)))
except ValueError:
pass
# Try YYYY-MM-DD format
match = re.search(r'(\d{4})-(\d{2})-(\d{2})', date_str)
if match:
try:
return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
except ValueError:
pass
return None
def parse_money(money_str: str) -> Optional[Decimal]:
"""Parse money value from Polish format (e.g., '4.000,00' or '5 000,00')"""
if not money_str:
return None
# Remove spaces
cleaned = money_str.replace(' ', '')
# Handle Polish format: dot as thousands separator, comma as decimal separator
# e.g., "4.000,00" should become "4000.00"
# First, check if we have Polish format (comma present)
if ',' in cleaned:
# Remove dots (thousands separators) and replace comma with dot (decimal separator)
cleaned = cleaned.replace('.', '').replace(',', '.')
else:
# US format or simple integer - dots are decimal separators
pass
# Extract number
match = re.search(r'([\d\.]+)', cleaned)
if match:
try:
return Decimal(match.group(1))
except:
pass
return None
def parse_shares_info(shares_str: str) -> Tuple[Optional[int], Optional[Decimal]]:
"""Parse shares count and value from string like '80 UDZIAŁÓW O ŁĄCZNEJ WARTOŚCI 4.000,00 ZŁ'"""
count = None
value = None
# Count pattern
count_match = re.search(r'(\d+)\s+UDZIAŁ', shares_str, re.IGNORECASE)
if count_match:
count = int(count_match.group(1))
# Value pattern
value_match = re.search(r'WARTOŚCI\s+([\d\s,\.]+)\s*ZŁ', shares_str, re.IGNORECASE)
if value_match:
value = parse_money(value_match.group(1))
return count, value
def extract_person_block(lines: List[str], start_idx: int, role_category: str) -> Optional[KRSPerson]:
"""Extract person data from PDF text block"""
person = KRSPerson(nazwisko="", imiona="", rola_kategoria=role_category)
found_nazwisko = False
found_imiona = False
for i in range(start_idx, min(start_idx + 15, len(lines))):
line = lines[i].strip()
# Stop at next person block
if i > start_idx and ('1.Nazwisko' in line or 'Nazwisko / Nazwa' in line):
break
# Nazwisko
if not found_nazwisko and 'Nazwisko' in line and ' - ' in line:
match = re.search(r' - ([A-ZĄĆĘŁŃÓŚŹŻ\-]+)$', line)
if match:
person.nazwisko = match.group(1)
found_nazwisko = True
# Imiona
if not found_imiona and 'Imiona' in line and ' - ' in line:
match = re.search(r' - ([A-ZĄĆĘŁŃÓŚŹŻ ]+)$', line)
if match:
person.imiona = match.group(1).strip()
found_imiona = True
# PESEL
if 'PESEL' in line and ' - ' in line:
match = re.search(r' - (\d{11})', line)
if match:
person.pesel = match.group(1)
# Function (for board members)
if 'Funkcja' in line and ' - ' in line:
match = re.search(r' - ([A-ZĄĆĘŁŃÓŚŹŻ ]+)$', line)
if match:
person.rola = match.group(1).strip()
# Shares (for shareholders)
if 'udziały' in line.lower() and ' - ' in line:
match = re.search(r' - (.+)$', line)
if match:
shares_str = match.group(1)
count, value = parse_shares_info(shares_str)
person.udzialy_liczba = count
person.udzialy_wartosc = value
if person.nazwisko and person.imiona:
return person
return None
def parse_krs_pdf_full(pdf_path: str) -> KRSFullData:
"""
Parse KRS PDF and extract all available data.
Args:
pdf_path: Path to KRS PDF file
Returns:
KRSFullData object with all extracted data
"""
text = extract_text_from_pdf(pdf_path)
lines = text.split('\n')
data = KRSFullData(krs="", data_pobrania=datetime.now())
# === Basic identifiers ===
# KRS number
krs_match = re.search(r'Numer KRS:\s*(\d{10})', text)
if krs_match:
data.krs = krs_match.group(1)
# NIP and REGON
nip_match = re.search(r'NIP:\s*(\d{10})', text)
if nip_match:
data.nip = nip_match.group(1)
regon_match = re.search(r'REGON:\s*(\d{9,14})', text)
if regon_match:
data.regon = regon_match.group(1)
# === Company name and form ===
# Company name
nazwa_match = re.search(r'3\.Firma,?\s+pod którą spółka działa\s+\d+\s+-\s+([^\n]+)', text)
if nazwa_match:
data.nazwa = nazwa_match.group(1).strip()
# Legal form
forma_match = re.search(r'1\.Oznaczenie formy prawnej\s+\d+\s+-\s+([^\n]+)', text)
if forma_match:
data.forma_prawna = forma_match.group(1).strip()
# OPP status
opp_match = re.search(r'status organizacji\s*pożytku publicznego\?\s+\d+\s+-\s+(TAK|NIE)', text, re.IGNORECASE)
if opp_match:
data.czy_opp = opp_match.group(1).upper() == 'TAK'
# === Registration date ===
# Find first entry date
reg_match = re.search(r'Nr wpisu 1 Data dokonania wpisu (\d{2}\.\d{2}\.\d{4})', text)
if reg_match:
data.data_rejestracji = parse_date(reg_match.group(1))
# Company agreement date
umowa_match = re.search(r'Informacja o zawarciu.+?(\d{2}\.\d{2}\.\d{4})', text, re.DOTALL)
if umowa_match:
data.data_umowy_spolki = parse_date(umowa_match.group(1))
# Duration
czas_match = re.search(r'Czas,?\s+na jaki została utworzona spółka\s+\d+\s+-\s+([^\n]+)', text)
if czas_match:
data.czas_trwania = czas_match.group(1).strip()
# === Address ===
data.siedziba = KRSAddress()
# Full address line
adres_match = re.search(
r'2\.Adres\s+\d+\s+-\s+ul\.\s*([^,]+),\s*nr\s*(\d+\w*)'
r'(?:,\s*lok\.\s*([^,]+))?'
r',\s*miejsc\.\s*([^,]+)'
r',\s*kod\s*(\d{2}-\d{3})'
r',\s*poczta\s*([^,]+)',
text, re.IGNORECASE
)
if adres_match:
data.siedziba.ulica = adres_match.group(1).strip()
data.siedziba.numer_domu = adres_match.group(2).strip()
if adres_match.group(3):
data.siedziba.numer_lokalu = adres_match.group(3).strip()
data.siedziba.miejscowosc = adres_match.group(4).strip()
data.siedziba.kod_pocztowy = adres_match.group(5).strip()
data.siedziba.poczta = adres_match.group(6).strip()
# Siedziba (province info)
siedziba_match = re.search(
r'1\.Siedziba\s+\d+\s+-\s+kraj\s+POLSKA,\s*woj\.\s*([^,]+),\s*powiat\s*([^,]+),\s*gmina\s*([^,]+)',
text, re.IGNORECASE
)
if siedziba_match:
data.siedziba.wojewodztwo = siedziba_match.group(1).strip()
data.siedziba.powiat = siedziba_match.group(2).strip()
data.siedziba.gmina = siedziba_match.group(3).strip()
# Email
email_match = re.search(r'3\.Adres poczty elektronicznej\s+\d+\s+-\s+([^\n]+)', text)
if email_match:
data.siedziba.email = email_match.group(1).strip()
# Website
www_match = re.search(r'4\.Adres strony internetowej\s+\d+\s+-\s+([^\n]+)', text)
if www_match:
data.siedziba.www = www_match.group(1).strip()
# === Capital ===
kapital_match = re.search(r'1\.Wysokość kapitału zakładowego\s+\d+\s+-\s+([\d\s,\.]+)\s*ZŁ', text, re.IGNORECASE)
if kapital_match:
data.kapital_zakladowy = parse_money(kapital_match.group(1))
# === Representation rules ===
repr_match = re.search(r'2\.Sposób reprezentacji podmiotu\s+\d+\s+-\s+([^\n]+(?:\n[^\n]*)?)', text)
if repr_match:
# Clean up multiline representation
repr_text = repr_match.group(1).strip()
# Remove line breaks and extra spaces
repr_text = ' '.join(repr_text.split())
data.sposob_reprezentacji = repr_text
# === PKD codes ===
# Main PKD
pkd_glowny_match = re.search(
r'1\.Przedmiot przeważającej\s+\d+\s+\d+\s+-\s+(\d+),\s*(\d+),\s*([A-Z]),\s*([^\n]+)',
text
)
if pkd_glowny_match:
kod = f"{pkd_glowny_match.group(1)}.{pkd_glowny_match.group(2)}.{pkd_glowny_match.group(3)}"
opis = pkd_glowny_match.group(4).strip()
data.pkd_przewazajacy = KRSPKD(kod=kod, opis=opis, jest_przewazajacy=True)
# Secondary PKDs
pkd_pozostale = re.findall(
r'2\.Przedmiot pozostałej działalności\s+\d+\s+\d+\s+-\s+(\d+),\s*(\d+),\s*([A-Z]),\s*([^\n]+)',
text
)
for match in pkd_pozostale:
kod = f"{match[0]}.{match[1]}.{match[2]}"
opis = match[3].strip()
data.pkd_pozostale.append(KRSPKD(kod=kod, opis=opis, jest_przewazajacy=False))
# === People ===
in_zarzad = False
in_wspolnicy = False
in_prokurenci = False
in_rada = False
for i, line in enumerate(lines):
line_stripped = line.strip()
# Section detection
if 'ZARZĄD' in line_stripped.upper() and 'Nazwa organu' in line_stripped:
in_zarzad = True
in_wspolnicy = False
in_prokurenci = False
in_rada = False
continue
if 'Dane wspólników' in line_stripped or ('Rubryka 7' in line_stripped and 'wspólników' in line_stripped.lower()):
in_wspolnicy = True
in_zarzad = False
in_prokurenci = False
in_rada = False
continue
if 'Prokurenci' in line_stripped:
in_prokurenci = True
in_zarzad = False
in_wspolnicy = False
in_rada = False
continue
if 'Organ nadzoru' in line_stripped:
in_rada = True
in_zarzad = False
in_wspolnicy = False
in_prokurenci = False
continue
# New section - reset
if 'Dział' in line_stripped and re.match(r'Dział \d+', line_stripped):
if 'Dział 2' not in line_stripped and 'Dział 1' not in line_stripped:
in_zarzad = False
in_wspolnicy = False
in_prokurenci = False
in_rada = False
# Parse person when we find "Nazwisko"
if '1.Nazwisko' in line_stripped or 'Nazwisko / Nazwa' in line_stripped:
if in_zarzad:
person = extract_person_block(lines, i, 'zarzad')
if person:
if not person.rola:
person.rola = "CZŁONEK ZARZĄDU"
data.zarzad.append(person)
elif in_wspolnicy:
person = extract_person_block(lines, i, 'wspolnik')
if person:
person.rola = "WSPÓLNIK"
data.wspolnicy.append(person)
elif in_prokurenci:
person = extract_person_block(lines, i, 'prokurent')
if person:
person.rola = "PROKURENT"
data.prokurenci.append(person)
elif in_rada:
person = extract_person_block(lines, i, 'rada_nadzorcza')
if person:
person.rola = "CZŁONEK RADY NADZORCZEJ"
data.rada_nadzorcza.append(person)
# Calculate share percentage for shareholders
if data.kapital_zakladowy and data.wspolnicy:
for wspolnik in data.wspolnicy:
if wspolnik.udzialy_wartosc:
wspolnik.udzialy_procent = Decimal(str(
round(float(wspolnik.udzialy_wartosc) / float(data.kapital_zakladowy) * 100, 2)
))
# Calculate total shares
total_shares = sum(w.udzialy_liczba or 0 for w in data.wspolnicy)
if total_shares > 0:
data.liczba_udzialow = total_shares
if data.kapital_zakladowy:
data.wartosc_nominalna_udzialu = Decimal(str(
round(float(data.kapital_zakladowy) / total_shares, 2)
))
# === Financial reports ===
# Parse financial report filings - multiple patterns needed
# Format 1: "1.Wzmianka o złożeniu rocznego 1 4 - 22.06.2022 OD 05.02.2021 DO 31.12.2021"
# Format 2: "2 6 - 20.06.2023 OD 01.01.2022 DO 31.12.2022" (continuation lines)
report_matches = re.findall(
r'(\d{2}\.\d{2}\.\d{4})\s+OD\s+(\d{2}\.\d{2}\.\d{4})\s+DO\s+(\d{2}\.\d{2}\.\d{4})',
text
)
seen_reports = set()
for match in report_matches:
# Deduplicate by period
key = (match[1], match[2])
if key not in seen_reports:
seen_reports.add(key)
report = KRSFinancialReport(
data_zlozenia=parse_date(match[0]),
okres_od=parse_date(match[1]),
okres_do=parse_date(match[2])
)
data.sprawozdania_finansowe.append(report)
# Sort by period end date
data.sprawozdania_finansowe.sort(key=lambda r: r.okres_do or date.min)
return data
# ============================================================
# MAIN ENTRY POINT
# ============================================================
def parse_krs_pdf(pdf_path: str, verbose: bool = False) -> Dict[str, Any]:
"""
Parse KRS PDF and return dictionary with all data.
This is the main entry point for external use.
Args:
pdf_path: Path to KRS PDF file
verbose: Print debug information
Returns:
Dictionary with all extracted data
"""
if verbose:
logger.info(f"Parsing: {pdf_path}")
data = parse_krs_pdf_full(pdf_path)
if verbose:
logger.info(f" KRS: {data.krs}")
logger.info(f" Nazwa: {data.nazwa}")
logger.info(f" NIP: {data.nip}, REGON: {data.regon}")
logger.info(f" Kapitał: {data.kapital_zakladowy} {data.waluta}")
logger.info(f" Zarząd: {len(data.zarzad)} osób")
logger.info(f" Wspólnicy: {len(data.wspolnicy)} osób")
if data.pkd_przewazajacy:
logger.info(f" PKD główny: {data.pkd_przewazajacy.kod}")
return data.to_dict()
def main():
"""CLI entry point for testing"""
import argparse
parser = argparse.ArgumentParser(description="Parse KRS PDF files (full data extraction)")
parser.add_argument("--file", type=str, help="Single PDF file to parse")
parser.add_argument("--dir", type=str, help="Directory with PDF files")
parser.add_argument("--output", type=str, help="Output JSON file")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
args = parser.parse_args()
results = []
if args.file:
data = parse_krs_pdf(args.file, verbose=args.verbose)
results.append(data)
# Print summary
print(f"\n{'='*60}")
print(f"{data['nazwa']} (KRS: {data['krs']})")
print(f"{'='*60}")
print(f"NIP: {data['nip']}, REGON: {data['regon']}")
print(f"Forma prawna: {data['forma_prawna']}")
print(f"Data rejestracji: {data['data_rejestracji']}")
if data['siedziba']:
addr = data['siedziba']
print(f"Adres: {addr.get('ulica', '')} {addr.get('numer_domu', '')}, {addr.get('kod_pocztowy', '')} {addr.get('miejscowosc', '')}")
if addr.get('email'):
print(f"Email: {addr['email']}")
if addr.get('www'):
print(f"WWW: {addr['www']}")
print(f"\nKapitał zakładowy: {data['kapital_zakladowy']} {data['waluta']}")
print(f"Liczba udziałów: {data['liczba_udzialow']}")
print(f"Wartość nominalna: {data['wartosc_nominalna_udzialu']} {data['waluta']}")
print(f"\nSposób reprezentacji: {data['sposob_reprezentacji']}")
if data['pkd_przewazajacy']:
print(f"\nPKD przeważający: {data['pkd_przewazajacy']['kod']} - {data['pkd_przewazajacy']['opis']}")
if data['pkd_pozostale']:
print("PKD pozostałe:")
for pkd in data['pkd_pozostale']:
print(f" - {pkd['kod']}: {pkd['opis']}")
print(f"\nZarząd ({len(data['zarzad'])} osób):")
for p in data['zarzad']:
print(f" - {p['imiona']} {p['nazwisko']} - {p['rola']}")
print(f"\nWspólnicy ({len(data['wspolnicy'])} osób):")
for p in data['wspolnicy']:
shares_info = ""
if p.get('udzialy_liczba'):
shares_info = f" ({p['udzialy_liczba']} udziałów, {p.get('udzialy_procent', '?')}%)"
print(f" - {p['imiona']} {p['nazwisko']}{shares_info}")
if data['prokurenci']:
print(f"\nProkurenci ({len(data['prokurenci'])} osób):")
for p in data['prokurenci']:
print(f" - {p['imiona']} {p['nazwisko']}")
if data['sprawozdania_finansowe']:
print(f"\nSprawozdania finansowe ({len(data['sprawozdania_finansowe'])}):")
for sf in data['sprawozdania_finansowe']:
print(f" - {sf['okres_od']} do {sf['okres_do']} (złożone: {sf['data_zlozenia']})")
elif args.dir:
pdf_dir = Path(args.dir)
pdf_files = list(pdf_dir.glob("*.pdf"))
print(f"Found {len(pdf_files)} PDF files")
for pdf_file in pdf_files:
try:
data = parse_krs_pdf(str(pdf_file), verbose=args.verbose)
results.append(data)
print(f"{data['nazwa']} (KRS: {data['krs']})")
except Exception as e:
print(f"{pdf_file.name}: {e}")
# Save results
if args.output and results:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\nResults saved to: {args.output}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,869 @@
{% extends "base.html" %}
{% block title %}Panel Audyt KRS - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.data-source-info {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
margin-top: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--info-light, #e0f2fe);
border-radius: var(--radius);
font-size: var(--font-size-sm);
color: var(--info, #0284c7);
}
.data-source-info svg {
flex-shrink: 0;
}
.data-source-info a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
/* Summary Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
text-align: center;
}
.stat-number {
font-size: var(--font-size-2xl);
font-weight: 700;
display: block;
margin-bottom: var(--spacing-xs);
}
.stat-number.green { color: var(--success); }
.stat-number.yellow { color: var(--warning); }
.stat-number.red { color: var(--error); }
.stat-number.gray { color: var(--secondary); }
.stat-number.blue { color: var(--primary); }
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Progress Section */
.progress-section {
background: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-xl);
display: none;
}
.progress-section.active {
display: block;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.progress-title {
font-size: var(--font-size-lg);
font-weight: 600;
}
.progress-bar-container {
height: 24px;
background: var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: var(--spacing-sm);
}
.progress-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 12px;
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: var(--font-size-sm);
}
.progress-message {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.progress-log {
max-height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: var(--font-size-sm);
background: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-top: var(--spacing-md);
}
.progress-log-entry {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--border);
}
.progress-log-entry.success { color: var(--success); }
.progress-log-entry.error { color: var(--error); }
.progress-log-entry.skip { color: var(--warning); }
/* Filters */
.filters-bar {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
background: white;
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.filter-group label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
min-width: 150px;
}
/* Table Container */
.table-container {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.krs-table {
width: 100%;
border-collapse: collapse;
}
.krs-table th,
.krs-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.krs-table th {
background: var(--background);
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
white-space: nowrap;
}
.krs-table tbody tr:hover {
background: var(--background);
}
.company-name-cell {
font-weight: 500;
max-width: 250px;
}
.company-name-cell a {
color: var(--text-primary);
text-decoration: none;
}
.company-name-cell a:hover {
color: var(--primary);
}
.krs-number {
font-family: monospace;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 500;
}
.status-badge.audited {
background: #dcfce7;
color: #166534;
}
.status-badge.pending {
background: #fef3c7;
color: #92400e;
}
.status-badge.error {
background: #fee2e2;
color: #991b1b;
}
/* Data cell */
.data-cell {
text-align: center;
font-size: var(--font-size-sm);
}
.data-value {
font-weight: 600;
color: var(--text-primary);
}
.data-label {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
}
.capital-value {
font-family: monospace;
white-space: nowrap;
}
/* Date cell */
.date-cell {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
}
.date-never {
color: var(--error);
font-style: italic;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: var(--spacing-xs);
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: var(--transition);
text-decoration: none;
color: var(--text-primary);
}
.btn-icon:hover {
background: var(--background);
border-color: var(--primary);
color: var(--primary);
}
.btn-icon.audit {
color: var(--success);
}
.btn-icon.audit:hover {
background: #dcfce7;
border-color: var(--success);
}
.btn-icon.pdf {
color: var(--error);
}
.btn-icon.pdf:hover {
background: #fee2e2;
border-color: var(--error);
}
.btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.modal-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.modal-icon.warning {
background: #fef3c7;
color: #d97706;
}
.modal-icon.success {
background: #dcfce7;
color: #16a34a;
}
.modal-title {
font-size: var(--font-size-xl);
font-weight: 600;
}
.modal-body {
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
/* Responsive */
@media (max-width: 1200px) {
.krs-table {
font-size: var(--font-size-sm);
}
.hide-mobile {
display: none;
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Panel Audyt KRS</h1>
<p class="text-muted">Ekstrakcja danych z odpisow KRS (Krajowy Rejestr Sadowy)</p>
<div class="data-source-info">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Dane z <a href="https://ekrs.ms.gov.pl/" target="_blank" rel="noopener">eKRS (ekrs.ms.gov.pl)</a></span>
</div>
</div>
<div class="header-actions">
{% if krs_audit_available %}
<button class="btn btn-primary btn-sm" onclick="runBatchAudit()" id="batchAuditBtn">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Uruchom audyt wszystkich
</button>
{% else %}
<span class="text-muted">Usluga audytu niedostepna</span>
{% endif %}
</div>
</div>
<!-- Summary Stats -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number blue">{{ stats.total_with_krs }}</span>
<span class="stat-label">Firm z KRS</span>
</div>
<div class="stat-card">
<span class="stat-number green">{{ stats.audited_count }}</span>
<span class="stat-label">Przeaudytowane</span>
</div>
<div class="stat-card">
<span class="stat-number yellow">{{ stats.not_audited_count }}</span>
<span class="stat-label">Oczekujace</span>
</div>
<div class="stat-card">
<span class="stat-number gray">{{ stats.no_krs_count }}</span>
<span class="stat-label">Bez KRS (JDG)</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.with_capital }}</span>
<span class="stat-label">Z kapitalem</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.with_people }}</span>
<span class="stat-label">Z zarzadem</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.with_pkd }}</span>
<span class="stat-label">Z PKD</span>
</div>
</div>
<!-- Progress Section (hidden by default) -->
<div class="progress-section" id="progressSection">
<div class="progress-header">
<span class="progress-title">Audyt w toku...</span>
<button class="btn btn-sm btn-outline" onclick="cancelAudit()">Anuluj</button>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progressBar" style="width: 0%">0%</div>
</div>
<div class="progress-message" id="progressMessage">Przygotowywanie...</div>
<div class="progress-log" id="progressLog"></div>
</div>
<!-- Filters -->
<div class="filters-bar">
<div class="filter-group">
<label for="filterStatus">Status:</label>
<select id="filterStatus" onchange="applyFilters()">
<option value="">Wszystkie</option>
<option value="audited">Przeaudytowane</option>
<option value="pending">Oczekujace</option>
</select>
</div>
<div class="filter-group">
<label for="filterSearch">Szukaj:</label>
<input type="text" id="filterSearch" placeholder="Nazwa lub KRS..." oninput="applyFilters()">
</div>
<div class="filter-group" style="margin-left: auto;">
<button class="btn btn-sm btn-outline" onclick="resetFilters()">Resetuj filtry</button>
</div>
</div>
<!-- Table -->
{% if companies %}
<div class="table-container">
<table class="krs-table" id="krsTable">
<thead>
<tr>
<th>Firma</th>
<th>KRS</th>
<th class="hide-mobile">Kapital</th>
<th class="hide-mobile">Zarzad</th>
<th class="hide-mobile">PKD</th>
<th>Status</th>
<th>Ostatni audyt</th>
<th>Akcje</th>
</tr>
</thead>
<tbody id="krsTableBody">
{% for company in companies %}
<tr data-name="{{ company.name|lower }}"
data-krs="{{ company.krs }}"
data-status="{{ 'audited' if company.krs_last_audit_at else 'pending' }}">
<td class="company-name-cell">
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
</td>
<td>
<span class="krs-number">{{ company.krs }}</span>
</td>
<td class="data-cell hide-mobile">
{% if company.capital_amount %}
<span class="capital-value">{{ "{:,.0f}".format(company.capital_amount|float).replace(",", " ") }} PLN</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="data-cell hide-mobile">
{% if company.people_count > 0 %}
<span class="data-value">{{ company.people_count }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="data-cell hide-mobile">
{% if company.pkd_count > 0 %}
<span class="data-value">{{ company.pkd_count }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if company.krs_last_audit_at %}
<span class="status-badge audited">
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
OK
</span>
{% else %}
<span class="status-badge pending">
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
Oczekuje
</span>
{% endif %}
</td>
<td class="date-cell">
{% if company.krs_last_audit_at %}
<span title="{{ company.krs_last_audit_at.strftime('%Y-%m-%d %H:%M') }}">
{{ company.krs_last_audit_at.strftime('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">Nigdy</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
<a href="{{ url_for('company_detail', company_id=company.id) }}" class="btn-icon" title="Zobacz profil">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
{% if company.krs_pdf_path %}
<a href="{{ url_for('api_krs_pdf_download', company_id=company.id) }}" class="btn-icon pdf" title="Pobierz PDF" target="_blank">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
</a>
{% endif %}
{% if krs_audit_available %}
<button class="btn-icon audit" onclick="runSingleAudit({{ company.id }}, '{{ company.name }}')" title="Uruchom audyt">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<h3>Brak firm z KRS</h3>
<p>Nie znaleziono firm z numerem KRS do audytu.</p>
</div>
{% endif %}
<!-- Confirmation Modal -->
<div class="modal" id="confirmModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-icon warning">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="modal-title" id="modalTitle">Potwierdz operacje</div>
</div>
<div class="modal-body" id="modalBody">
Czy na pewno chcesz wykonac te operacje?
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">Anuluj</button>
<button class="btn btn-primary" onclick="confirmModalAction()">Potwierdz</button>
</div>
</div>
</div>
<!-- Result Modal -->
<div class="modal" id="resultModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-icon success" id="resultIcon">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div class="modal-title" id="resultTitle">Sukces</div>
</div>
<div class="modal-body" id="resultBody">
Operacja zakonczona pomyslnie.
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeResultModal()">OK</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
let pendingModalAction = null;
let auditInProgress = false;
// Modal functions
function showModal(title, body, onConfirm) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').textContent = body;
pendingModalAction = onConfirm;
document.getElementById('confirmModal').classList.add('active');
}
function closeModal() {
document.getElementById('confirmModal').classList.remove('active');
pendingModalAction = null;
}
function confirmModalAction() {
if (pendingModalAction) {
pendingModalAction();
}
closeModal();
}
function showResultModal(title, body, success = true) {
document.getElementById('resultTitle').textContent = title;
document.getElementById('resultBody').textContent = body;
const icon = document.getElementById('resultIcon');
icon.className = 'modal-icon ' + (success ? 'success' : 'warning');
document.getElementById('resultModal').classList.add('active');
}
function closeResultModal() {
document.getElementById('resultModal').classList.remove('active');
location.reload();
}
// Close modal on backdrop click
document.getElementById('confirmModal')?.addEventListener('click', (e) => {
if (e.target.id === 'confirmModal') closeModal();
});
document.getElementById('resultModal')?.addEventListener('click', (e) => {
if (e.target.id === 'resultModal') closeResultModal();
});
// Filter functions
function applyFilters() {
const status = document.getElementById('filterStatus').value;
const search = document.getElementById('filterSearch').value.toLowerCase();
const rows = document.querySelectorAll('#krsTableBody tr');
rows.forEach(row => {
let show = true;
// Status filter
if (status && row.dataset.status !== status) {
show = false;
}
// Search filter
if (search && show) {
const name = row.dataset.name || '';
const krs = row.dataset.krs || '';
if (!name.includes(search) && !krs.includes(search)) {
show = false;
}
}
row.style.display = show ? '' : 'none';
});
}
function resetFilters() {
document.getElementById('filterStatus').value = '';
document.getElementById('filterSearch').value = '';
applyFilters();
}
// Audit functions
async function runSingleAudit(companyId, companyName) {
showModal(
'Uruchom audyt KRS',
`Czy chcesz uruchomic audyt KRS dla firmy "${companyName}"? Plik PDF musi byc dostepny w katalogu data/krs_pdfs/.`,
async () => {
try {
const response = await fetch('/api/krs/audit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ company_id: companyId })
});
const data = await response.json();
if (response.ok && data.success) {
showResultModal(
'Audyt zakonczon',
`Pomyslnie wyciagnieto dane dla ${companyName}. ` +
`Kapital: ${data.data?.kapital?.toLocaleString() || '-'} PLN, ` +
`Zarzad: ${data.data?.zarzad_count || 0} osob, ` +
`PKD: ${data.data?.pkd_count || 0}`,
true
);
} else {
showResultModal('Blad', data.error || 'Wystapil nieznany blad', false);
}
} catch (error) {
showResultModal('Blad polaczenia', 'Nie udalo sie polaczyc z serwerem: ' + error.message, false);
}
}
);
}
async function runBatchAudit() {
showModal(
'Uruchom audyt wszystkich firm',
'Czy chcesz uruchomic audyt KRS dla wszystkich firm? To moze potrwac kilka minut.',
async () => {
auditInProgress = true;
const btn = document.getElementById('batchAuditBtn');
btn.disabled = true;
btn.innerHTML = '<span>Audyt w toku...</span>';
const progressSection = document.getElementById('progressSection');
progressSection.classList.add('active');
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressLog = document.getElementById('progressLog');
progressBar.style.width = '5%';
progressBar.textContent = '5%';
progressMessage.textContent = 'Rozpoczynanie audytu...';
progressLog.innerHTML = '';
try {
const response = await fetch('/api/krs/audit/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (response.ok && data.success) {
progressBar.style.width = '100%';
progressBar.textContent = '100%';
progressMessage.textContent = data.message;
// Show details in log
if (data.results && data.results.details) {
data.results.details.forEach(item => {
const entry = document.createElement('div');
entry.className = 'progress-log-entry ' + item.status;
entry.textContent = `${item.company} (${item.krs}): ${item.status}${item.reason ? ' - ' + item.reason : ''}`;
progressLog.appendChild(entry);
});
}
showResultModal(
'Audyt zakonczony',
`Sukces: ${data.results?.success || 0}, Bledy: ${data.results?.failed || 0}, Pominiete: ${data.results?.skipped || 0}`,
true
);
} else {
showResultModal('Blad', data.error || 'Wystapil nieznany blad', false);
}
} catch (error) {
showResultModal('Blad polaczenia', 'Nie udalo sie polaczyc z serwerem: ' + error.message, false);
} finally {
auditInProgress = false;
btn.disabled = false;
btn.innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Uruchom audyt wszystkich
`;
}
}
);
}
function cancelAudit() {
// Note: Currently can't cancel - just hide progress section
document.getElementById('progressSection').classList.remove('active');
}
{% endblock %}

View File

@ -1031,7 +1031,15 @@
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em;">KRS</div>
<div style="font-size: var(--font-size-xl); font-weight: 700; color: #ef4444; font-family: monospace;">{{ company.krs }}</div>
</div>
<div style="margin-left: auto;">
<div style="margin-left: auto; display: flex; gap: var(--spacing-sm); flex-wrap: wrap;">
{% if company.krs_pdf_path %}
<a href="/api/krs/pdf/{{ company.id }}" target="_blank" rel="noopener noreferrer"
style="padding: 8px 16px; background: #ef4444; color: white; border-radius: var(--radius); text-decoration: none; font-size: var(--font-size-sm); font-weight: 600; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px;"
title="Pobierz odpis pełny KRS w formacie PDF">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24"><path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M10,13H7V11H10V13M14,13H11V11H14V13M10,16H7V14H10V16M14,16H11V14H14V16Z"/></svg>
Odpis PDF
</a>
{% endif %}
<a href="https://rejestr.io/krs/{{ company.krs|int }}" target="_blank" rel="noopener noreferrer"
style="padding: 8px 16px; background: #10b981; color: white; border-radius: var(--radius); text-decoration: none; font-size: var(--font-size-sm); font-weight: 600; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px;"
title="Powiązania osobowe, władze, beneficjenci, pomoc publiczna">
@ -1042,6 +1050,12 @@
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); padding-left: 60px; margin-top: var(--spacing-sm);">
Źródło: <strong style="color: #10b981;">Krajowy Rejestr Sądowy</strong>
{% if company.krs_last_audit_at %}
<span style="margin-left: var(--spacing-md); color: #6366f1;">
<svg width="12" height="12" fill="currentColor" viewBox="0 0 24 24" style="vertical-align: middle;"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
Audyt KRS: {{ company.krs_last_audit_at.strftime('%d.%m.%Y') }}
</span>
{% endif %}
</div>
</div>
{% else %}