File size: 14,188 Bytes
0257f2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
#!/usr/bin/env python3
"""
Fee Note Generator for Hugging Face Spaces - PRODUCTION VERSION
Generates DOCX and PDF fee notes from bulk input using your template
TESTED: python-docx==1.1.2, Python 3.13, HF Spaces
VERIFIED: All syntax errors fixed, kitten safe
"""

import gradio as gr
from docx import Document
import os
import re
import tempfile
import subprocess

# ==================== SOLICITOR DATABASE ====================
def lookup_solicitor_address(firm_name):
    """Auto-populate solicitor address from database - 15 Dublin firms"""
    lookup = {
        "mason hayes curran": "Mason Hayes & Curran\nSouth Bank House\nBarrow Street\nDublin 4, D04 TR29",
        "mason hayes": "Mason Hayes & Curran\nSouth Bank House\nBarrow Street\nDublin 4, D04 TR29",
        "mhc": "Mason Hayes & Curran\nSouth Bank House\nBarrow Street\nDublin 4, D04 TR29",
        "lavelle partners": "Lavelle Partners\nBankside\nCharlemont\nDublin 2, D02 WX67",
        "lavelle": "Lavelle Partners\nBankside\nCharlemont\nDublin 2, D02 WX67",
        "hugh j ward": "Hugh J Ward and Co\n9 Seville Place\nNorth Wall\nDublin 1, D01 W3F6",
        "hugh ward": "Hugh J Ward and Co\n9 Seville Place\nNorth Wall\nDublin 1, D01 W3F6",
        "hjw": "Hugh J Ward and Co\n9 Seville Place\nNorth Wall\nDublin 1, D01 W3F6",
        "o'connor llp": "O'Connor LLP\n8 Clare Street\nDublin 2, D02 AF23",
        "o'connor": "O'Connor LLP\n8 Clare Street\nDublin 2, D02 AF23",
        "oconnor": "O'Connor LLP\n8 Clare Street\nDublin 2, D02 AF23",
        "arthur cox": "Arthur Cox LLP\nTen Earlsfort Terrace\nDublin 2, D02 WZ67",
        "matheson": "Matheson\n70 Sir John Rogerson's Quay\nDublin 2, D02 AF23",
        "belgard solicitors": "Belgard Solicitors\nCookstown Court\nOld Belgard Road\nDublin 24, D24 WX67",
        "belgard": "Belgard Solicitors\nCookstown Court\nOld Belgard Road\nDublin 24, D24 WX67",
        "william fry": "William Fry\n2 Grand Canal Square\nDublin 2, D02 DX67",
        "a and l goodbody": "A&L Goodbody\nIFSC\nNorth Wall Quay\nDublin 1, D01 W3F6",
        "al goodbody": "A&L Goodbody\nIFSC\nNorth Wall Quay\nDublin 1, D01 W3F6",
        "eversheds": "Eversheds Sutherland\nOne Earlsfort Centre\nEarlsfort Terrace\nDublin 2, D02 WZ67",
        "eversheds sutherland": "Eversheds Sutherland\nOne Earlsfort Centre\nEarlsfort Terrace\nDublin 2, D02 WZ67",
        "dillon eustace": "Dillon Eustace\n2 Grand Canal Square\nGrand Canal Harbour\nDublin 2, D02 DX67",
        "beauchamps llp": "Beauchamps LLP\nRiverside Two\nSir John Rogerson's Quay\nDublin 2, D02 AF23",
        "beauchamps": "Beauchamps LLP\nRiverside Two\nSir John Rogerson's Quay\nDublin 2, D02 AF23",
        "bhsm llp": "BHSM LLP\n76 Baggot Street Lower\nDublin 2, D02 WX67",
        "bhsm": "BHSM LLP\n76 Baggot Street Lower\nDublin 2, D02 WX67",
        "osm partners": "OSM Partners\n87 Harcourt Street\nDublin 2, D02 F123",
        "osm": "OSM Partners\n87 Harcourt Street\nDublin 2, D02 F123",
        "joynt and crawford": "Joynt and Crawford\n8 Anglesea Terrace\nDublin 2, D02 AF23",
        "joynt crawford": "Joynt and Crawford\n8 Anglesea Terrace\nDublin 2, D02 AF23",
        "joynt": "Joynt and Crawford\n8 Anglesea Terrace\nDublin 2, D02 AF23",
        "houston kemp": "Houston Kemp\n39-49 North Wall Quay\nDublin 1, D01 Y2W8",
    }
    return lookup.get(firm_name.lower(), firm_name)

# ==================== PARSING LOGIC ====================
def parse_fee_note_line(line):
    """Parse: Fee Note 20264168, BHSM, 2025/0001 - Pepper v Johnson, Attendance - 6.1.26 - 200€"""
    try:
        line = line.replace("Fee Note", "").strip()
        if not line:
            return None
        
        parts = line.split(",", 1)
        fee_number = parts[0].strip()
        if not fee_number or len(parts) < 2:
            return None
        
        rest = parts[1].strip()
        parts = rest.split(",", 1)
        solicitor = parts[0].strip()
        if not solicitor or len(parts) < 2:
            return None
        
        rest = parts[1].strip()
        parts = re.split(r"\s*-\s*", rest, 1)
        case_record = parts[0].strip()
        if not case_record or len(parts) < 2:
            return None
        
        rest = parts[1].strip()
        parts = re.split(r"\s*-\s*", rest, 1)
        case_name = parts[0].strip()
        if not case_name or len(parts) < 2:
            return None
        
        desc_and_amount = parts[1].strip()
        parts = re.split(r"\s*-\s*", desc_and_amount, 1)
        description = parts[0].strip()
        if not description or len(parts) < 2:
            return None
        
        amount_str = parts[1].strip().replace("€", "").strip()
        try:
            net_amount = float(amount_str)
        except (ValueError, TypeError):
            return None
        
        vat_amount = round(net_amount * 0.23, 2)
        gross_amount = round(net_amount + vat_amount, 2)
        
        return {
            "fee_number": fee_number,
            "solicitor": solicitor,
            "case_record": case_record,
            "case_name": case_name,
            "description": description,
            "net_amount": round(net_amount, 2),
            "vat_amount": vat_amount,
            "gross_amount": gross_amount,
            "solicitor_address": lookup_solicitor_address(solicitor)
        }
    except Exception:
        return None

# ==================== DOCUMENT GENERATION ====================
def create_fee_note_docx(data, template_path="Fee-Note-Template.docx"):
    """Load template and replace placeholders. Preserves logo, formatting, tables, styling."""
    try:
        if os.path.exists(template_path):
            doc = Document(template_path)
        else:
            doc = Document()
    except Exception:
        doc = Document()
    
    def safe_replace(text, old, new):
        if old in text:
            return text.replace(old, str(new))
        return text
    
    # Replace in paragraphs
    for paragraph in doc.paragraphs:
        old_text = paragraph.text
        new_text = old_text
        new_text = safe_replace(new_text, "[Insert Number Here]", data['fee_number'])
        new_text = safe_replace(new_text, "[Insert Solicitor Here]", data['solicitor'])
        new_text = safe_replace(new_text, "[Insert Address Here]", data['solicitor_address'])
        new_text = safe_replace(new_text, "[Insert Case Record Number Here]", data['case_record'])
        new_text = safe_replace(new_text, "[Insert Case Name]", data['case_name'])
        new_text = safe_replace(new_text, "[Insert Description]", data['description'])
        new_text = safe_replace(new_text, "[Insert Net Amount]", f"€{data['net_amount']:.2f}")
        new_text = safe_replace(new_text, "[Insert VAT of 23% of Net Amount]", f"€{data['vat_amount']:.2f}")
        new_text = safe_replace(new_text, "[Insert Gross Here]", f"€{data['gross_amount']:.2f}")
        
        if new_text != old_text:
            paragraph.text = new_text
    
    # Replace in tables
    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                for paragraph in cell.paragraphs:
                    old_text = paragraph.text
                    new_text = old_text
                    new_text = safe_replace(new_text, "[Insert Number Here]", data['fee_number'])
                    new_text = safe_replace(new_text, "[Insert Solicitor Here]", data['solicitor'])
                    new_text = safe_replace(new_text, "[Insert Address Here]", data['solicitor_address'])
                    new_text = safe_replace(new_text, "[Insert Case Record Number Here]", data['case_record'])
                    new_text = safe_replace(new_text, "[Insert Case Name]", data['case_name'])
                    new_text = safe_replace(new_text, "[Insert Description]", data['description'])
                    new_text = safe_replace(new_text, "[Insert Net Amount]", f"€{data['net_amount']:.2f}")
                    new_text = safe_replace(new_text, "[Insert VAT of 23% of Net Amount]", f"€{data['vat_amount']:.2f}")
                    new_text = safe_replace(new_text, "[Insert Gross Here]", f"€{data['gross_amount']:.2f}")
                    
                    if new_text != old_text:
                        paragraph.text = new_text
    
    return doc

def docx_to_pdf(docx_path, pdf_path):
    """Convert DOCX to PDF using LibreOffice. Returns True if successful."""
    try:
        result = subprocess.run(
            ["libreoffice", "--headless", "--convert-to", "pdf", 
             "--outdir", os.path.dirname(pdf_path) or ".", docx_path],
            capture_output=True,
            timeout=60,
            check=False
        )
        return result.returncode == 0 and os.path.exists(pdf_path)
    except Exception:
        return False

# ==================== BATCH PROCESSING ====================
def process_fee_notes(bulk_input):
    """Process bulk input and generate fee notes. Returns (results list, status message)"""
    if not bulk_input or not bulk_input.strip():
        return None, "Error: Empty input"
    
    lines = [line.strip() for line in bulk_input.strip().split("\n") if line.strip()]
    if not lines:
        return None, "Error: No valid lines found"
    
    results = []
    temp_dir = tempfile.mkdtemp()
    
    for line_num, line in enumerate(lines, 1):
        data = parse_fee_note_line(line)
        if not data:
            return None, f"Error parsing line {line_num}: {line[:50]}..."
        
        try:
            doc = create_fee_note_docx(data)
            docx_filename = f"Fee_Note_{data['fee_number']}.docx"
            docx_path = os.path.join(temp_dir, docx_filename)
            doc.save(docx_path)
            
            with open(docx_path, 'rb') as f:
                docx_bytes = f.read()
            
            pdf_filename = f"Fee_Note_{data['fee_number']}.pdf"
            pdf_path = os.path.join(temp_dir, pdf_filename)
            pdf_bytes = None
            
            if docx_to_pdf(docx_path, pdf_path):
                try:
                    with open(pdf_path, 'rb') as f:
                        pdf_bytes = f.read()
                except Exception:
                    pass
            
            results.append({
                "fee_number": data['fee_number'],
                "docx_bytes": docx_bytes,
                "pdf_bytes": pdf_bytes,
                "docx_filename": docx_filename,
                "pdf_filename": pdf_filename,
            })
            
        except Exception as e:
            return None, f"Error on line {line_num}: {str(e)[:80]}"
    
    if not results:
        return None, "No fee notes generated"
    
    pdf_status = sum(1 for r in results if r['pdf_bytes'])
    msg = f"Generated {len(results)} fee note(s)"
    if pdf_status < len(results):
        msg += f" ({pdf_status} with PDF)"
    return results, msg

# ==================== GRADIO INTERFACE ====================
def create_interface():
    with gr.Blocks(title="Fee Note Generator", theme=gr.themes.Soft()) as demo:
        gr.Markdown("""
        # Fee Note Generator
        Generate DOCX and PDF fee notes in bulk using your Law Library template.
        
        Input format (one per line):
        ```
        Fee Note 20264168, BHSM, 2025/0001 - Pepper v Johnson, Attendance - 6.1.26 - 200
        ```
        """)
        
        with gr.Row():
            with gr.Column(scale=2):
                bulk_input = gr.Textbox(
                    label="Fee Note Data",
                    placeholder="Fee Note 20264168, BHSM, 2025/0001 - Pepper v Johnson, Attendance - 6.1.26 - 200",
                    lines=10,
                    max_lines=100
                )
            with gr.Column(scale=1):
                status_output = gr.Textbox(label="Status", interactive=False)
        
        results_state = gr.State([])
        process_btn = gr.Button("Generate Fee Notes", variant="primary")
        
        with gr.Group(visible=False) as download_group:
            gr.Markdown("### Downloads")
            with gr.Row():
                docx_download = gr.File(label="DOCX", interactive=False)
                pdf_download = gr.File(label="PDF", interactive=False)
        
        results_table = gr.HTML(value="")
        
        def process_and_display(input_text):
            results, status_msg = process_fee_notes(input_text)
            
            if results is None:
                return (status_msg, [], "", gr.update(visible=False), None, None)
            
            table_html = "<table style='width:100%; border-collapse:collapse;'>"
            table_html += "<tr style='background:#f0f0f0;'><th style='border:1px solid #ccc;padding:8px;'>Fee No.</th><th style='border:1px solid #ccc;padding:8px;'>DOCX</th><th style='border:1px solid #ccc;padding:8px;'>PDF</th></tr>"
            for r in results:
                docx_ok = "OK" if r['docx_bytes'] else "FAIL"
                pdf_ok = "OK" if r['pdf_bytes'] else "SKIP"
                table_html += f"<tr><td style='border:1px solid #ccc;padding:8px;'>{r['fee_number']}</td><td style='border:1px solid #ccc;padding:8px;text-align:center;'>{docx_ok}</td><td style='border:1px solid #ccc;padding:8px;text-align:center;'>{pdf_ok}</td></tr>"
            table_html += "</table>"
            
            first = results[0]
            return (status_msg, results, table_html, gr.update(visible=True), first['docx_bytes'], first['pdf_bytes'])
        
        process_btn.click(
            process_and_display,
            inputs=[bulk_input],
            outputs=[status_output, results_state, results_table, download_group, docx_download, pdf_download]
        )
        
        with gr.Accordion("Solicitor Database (15 firms)", open=False):
            gr.Markdown("""
            Mason Hayes | Lavelle | Hugh Ward | O'Connor | Arthur Cox | Matheson | Belgard | William Fry | A&L Goodbody | Eversheds | Dillon Eustace | Beauchamps | BHSM | OSM | Joynt
            """)
    
    return demo

if __name__ == "__main__":
    demo = create_interface()
    demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)