File size: 36,867 Bytes
04f5706
 
e78a15b
04f5706
29c9f06
 
 
 
 
eba8462
29c9f06
 
 
 
 
 
 
1d64c21
29c9f06
 
eba8462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c07eb4
eba8462
8c07eb4
13dbdea
e78a15b
 
 
 
8c07eb4
eba8462
 
8c07eb4
 
eba8462
5c4cd18
 
 
 
1d64c21
5c4cd18
 
eba8462
 
e0a1687
5c4cd18
 
eba8462
8c07eb4
 
 
 
 
 
 
 
 
 
 
 
eba8462
199d4e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25de167
eba8462
 
 
 
 
 
25de167
 
 
 
 
 
 
 
 
a1f2b11
eba8462
e78a15b
eba8462
 
13dbdea
a1f2b11
 
bf65493
a1f2b11
eba8462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
082f9ce
 
 
e78a15b
082f9ce
 
 
 
 
 
 
 
 
e78a15b
 
322a638
287c847
 
 
 
 
 
322a638
082f9ce
a1f2b11
 
 
 
 
 
 
 
 
082f9ce
a1f2b11
 
 
082f9ce
 
 
 
287c847
eba8462
 
 
287c847
eba8462
 
 
 
287c847
eba8462
a877a35
 
eba8462
 
600f6c6
 
eba8462
 
 
 
 
 
13dbdea
199d4e4
8f9e54a
199d4e4
8f9e54a
199d4e4
59021bf
778f1e2
 
8f9e54a
 
 
 
199d4e4
8f9e54a
199d4e4
 
 
600f6c6
eba8462
600f6c6
8f9e54a
eba8462
8f9e54a
eba8462
 
 
 
8f9e54a
600f6c6
199d4e4
 
e78a15b
 
 
 
 
 
199d4e4
 
 
 
 
 
e78a15b
 
 
 
13dbdea
778f1e2
 
600f6c6
199d4e4
 
600f6c6
8f9e54a
778f1e2
600f6c6
 
 
 
 
 
 
a877a35
eba8462
a877a35
eba8462
a877a35
 
 
 
 
 
 
 
 
 
eba8462
600f6c6
eba8462
8f9e54a
eba8462
 
 
 
8f9e54a
eba8462
 
 
 
194e389
eba8462
 
 
 
 
 
 
 
 
29c9f06
 
eba8462
 
 
 
29c9f06
 
eba8462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13dbdea
5d67b5c
 
29c9f06
5d67b5c
 
29c9f06
5d67b5c
 
29c9f06
 
 
5d67b5c
 
 
 
 
 
 
 
29c9f06
 
eba8462
 
 
 
600f6c6
 
eba8462
 
 
 
600f6c6
eba8462
 
 
600f6c6
eba8462
 
600f6c6
 
 
 
 
 
 
 
 
eba8462
a1f2b11
 
 
eba8462
 
 
 
b4d7bd0
 
 
a1f2b11
 
b4d7bd0
eba8462
600f6c6
 
 
 
5d67b5c
 
 
eba8462
5d67b5c
eba8462
600f6c6
 
 
 
eba8462
600f6c6
 
 
 
 
 
eba8462
600f6c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eba8462
 
 
 
 
 
 
 
 
194e389
eba8462
 
18bf3a0
 
 
 
 
 
 
 
 
92c174e
18bf3a0
92c174e
 
 
e67c188
92c174e
 
e67c188
92c174e
 
 
18bf3a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13dbdea
4cb99f5
 
13dbdea
4cb99f5
 
18bf3a0
4cb99f5
 
18bf3a0
 
4cb99f5
18bf3a0
4cb99f5
18bf3a0
4cb99f5
 
 
e67c188
 
4cb99f5
 
18bf3a0
 
 
4cb99f5
 
 
18bf3a0
2526110
e67c188
2526110
18bf3a0
e67c188
18bf3a0
e67c188
18bf3a0
 
 
 
 
 
 
 
 
 
4cb99f5
18bf3a0
 
 
 
 
 
 
4cb99f5
 
 
 
 
 
 
 
13dbdea
de0823a
18bf3a0
4cb99f5
18bf3a0
 
de0823a
 
 
 
18bf3a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4cb99f5
18bf3a0
 
 
4cb99f5
 
 
 
 
 
 
 
 
 
 
31b557c
 
 
 
 
 
 
 
 
 
 
 
de0823a
4cb99f5
e67c188
 
de0823a
e67c188
de0823a
 
 
 
 
4cb99f5
e67c188
de0823a
 
4cb99f5
 
 
 
31b557c
18bf3a0
 
 
4cb99f5
18bf3a0
 
 
 
31b557c
18bf3a0
4cb99f5
18bf3a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4cb99f5
18bf3a0
 
 
 
31b557c
4cb99f5
 
 
31b557c
18bf3a0
 
4cb99f5
18bf3a0
4cb99f5
 
 
 
 
 
18bf3a0
 
 
 
 
 
 
31b557c
 
 
18bf3a0
558156c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2526110
558156c
 
 
 
 
 
 
 
 
 
 
13dbdea
4cb99f5
 
 
558156c
4cb99f5
 
558156c
4cb99f5
 
558156c
 
4cb99f5
 
 
 
 
 
 
 
558156c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5ee7c9
4cb99f5
558156c
4cb99f5
 
 
d5ee7c9
 
4cb99f5
558156c
 
4cb99f5
d5ee7c9
 
4cb99f5
 
558156c
 
 
 
 
 
 
 
 
 
 
 
 
 
18bf3a0
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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
import pandas as pd
import pdfplumber
import re



# ==========================================
# 3. MARG PARSER ENGINE (STOCK)
# ==========================================
MARG_HEADERS = {
    # Identifiers & Groupings
    "s.no.", "s.no", "code", "sku", "hsn", "sac", "salt", "category", "store", "godown",
    # Entities
    "description", "product", "item", "particulars", "company", "supplier",
    # Quantities, Tracking & Movement
    "packing", "pack", "batch", "btch", "exp", "expiry", "mfg", "mfg.", "days",
    "op.stock", "opening", "purchase", "receipt", "sale", "outwards", "return", "brk/exp",
    "cl.stock", "closing", "stock", "ord qty", "qty", "free", "min", "max", "reorder", "short", "available", "avl. qty",
    # Financials
    "rate", "p.rate", "mrp", "value", "amount", "unit", "gst%", "tax", "total"
}


def marg_is_stop_line(words: list) -> bool:
    text = " ".join(w["text"] for w in words).lower()
    return "grand total" in text or "page total" in text or text.startswith("total")


def parse_universal_marg_pdf(pdf_path: str) -> list:
    data_tree = []
    with pdfplumber.open(pdf_path) as pdf:
        for page_num, page in enumerate(pdf.pages):
            words = page.extract_words(keep_blank_chars=False)
            if not words: continue

            y_buckets = {}
            for w in words:
                y_b = round(w["top"] / 4) * 4
                if w["text"].lower() in MARG_HEADERS:
                    y_buckets.setdefault(y_b, []).append(w)

            if not y_buckets: continue
            best_y = max(y_buckets.keys(), key=lambda y: len(y_buckets[y]))
            if len(y_buckets[best_y]) < 2: continue


            header_top = min([w["top"] for w in y_buckets[best_y]])
            


            # THE FIX: Tighten the merging gap from 15 down to 8 pixels
            anchor_words = [w for w in words if abs(w["top"] - header_top) < 10 and not re.match(r'^[-=]+$', w["text"])]
            if not anchor_words: continue
            anchor_words.sort(key=lambda w: w["x0"])

            columns = []
            current_label = {"text": anchor_words[0]["text"], "x0": anchor_words[0]["x0"], "x1": anchor_words[0]["x1"]}
            
            for w in anchor_words[1:]:
                gap = w["x0"] - current_label["x1"]
                
                # THE FIX: Smart Split. Force a split if the next word is a known independent column, 
                # otherwise allow standard spaces (up to 10px) to keep "Item Name" or "Sale Value" together.
                is_standalone_col = w["text"].lower() in {"days", "mrp", "rate", "ord qty", "qty", "value", "exp.", "expiry", "pack", "short"}
                
                if gap < 4 or (gap < 10 and not is_standalone_col): 
                    current_label["text"] += " " + w["text"]
                    current_label["x1"] = w["x1"]



                else:
                    columns.append({
                        "label": current_label["text"].title(), 
                        "center": (current_label["x0"] + current_label["x1"]) / 2.0,
                        "x0": current_label["x0"]
                    })
                    current_label = {"text": w["text"], "x0": w["x0"], "x1": w["x1"]}
                    
            columns.append({
                "label": current_label["text"].title(), 
                "center": (current_label["x0"] + current_label["x1"]) / 2.0, 
                "x0": current_label["x0"]
            })

            # Find descriptive column
            desc_index = 0
            target_words = ["description", "product", "item", "particulars", "company", "salt", "category", "store", "godown", "supplier"]
            for i, col in enumerate(columns):
                if any(word in col["label"].lower() for word in target_words):
                    desc_index = i
                    break

            # THE FIX: Draw dividers 5 pixels to the left of the NEXT column's start position.
            # This prevents S.No. numbers from spilling into the Item column.
            dividers = []
            for i in range(len(columns) - 1):
                dividers.append(columns[i+1]["x0"] - 5)
            if not dividers: dividers = [9999]


            lines = {}
            for w in words:
                if w["top"] < header_top + 10: continue
                y = round(w["top"] / 3) * 3
                lines.setdefault(y, []).append(w)

            # THE FIX: Filter out purely decorative lines AFTER grouping, not word-by-word.
            # This protects group headers like "=== SUN PHARMA ===" from having their equals signs stripped.
            clean_lines = {}
            for y, lw in lines.items():
                line_text_no_spaces = "".join(w["text"] for w in lw).replace(" ", "")
                if not re.match(r'^[-=]+$', line_text_no_spaces):
                    clean_lines[y] = lw
            lines = clean_lines


            current_block_name = f"Report View {page_num + 1}"
            current_group_name = None
            current_item = None


            dynamic_inline_keys = [col["label"].lower() + ":" for col in columns if len(col["label"]) > 2]



            for y in sorted(lines.keys()):
                lw = sorted(lines[y], key=lambda w: w["x0"])
                if not lw: continue
                if marg_is_stop_line(lw): break

                main_divider = dividers[desc_index] if len(dividers) > desc_index else dividers[0]
                fin_words = [w["text"] for w in lw if midpoint(w) >= main_divider]
                has_grid_data = any(any(char.isdigit() or char == '-' for char in text) for text in fin_words)

                col_bins = [[] for _ in columns]
                for w in lw:
                    cx = midpoint(w)
                    placed = False
                    for i in range(len(dividers)):
                        if cx < dividers[i]:
                            col_bins[i].append(w["text"])
                            placed = True
                            break
                    if not placed:
                        col_bins[-1].append(w["text"])

                text_str = " ".join(col_bins[desc_index]).strip()
                pre_desc_text = ""
                for i in range(desc_index): pre_desc_text += " ".join(col_bins[i]).strip()

                # if "total:" in text_str.lower() or "total :" in text_str.lower() or "grand total" in text_str.lower():
                #     continue

                if "total:" in text_str.lower() or "total :" in text_str.lower() or "grand total" in text_str.lower():
                    # THE FIX: Save and wipe current_item so the next line isn't falsely treated as a description continuation
                    if current_item:
                        clean_item = {k: v for k, v in current_item.items() if k != "_anchor_x0"}
                        block_node = next((n for n in data_tree if n["name"] == current_block_name), None)
                        if not block_node:
                            block_node = {"name": current_block_name, "type": "view", "items": []}
                            data_tree.append(block_node)
                        block_node["items"].append(clean_item)
                        current_item = None
                    continue


                is_continuation_candidate = (
                    current_item is not None
                    and current_x0 is not None
                    and current_item.get("_anchor_x0") is not None
                    and abs(current_x0 - current_item["_anchor_x0"]) <= 10
                )
                


                # Define full_line_text to catch headers regardless of which column they land in
                full_line_text = " ".join(w["text"] for w in lw).strip()
                full_line_lower = full_line_text.lower()
                
                # THE FIX: Dynamically check if the line contains at least 2 active column headers
                header_matches = sum(1 for key in dynamic_inline_keys if key in full_line_lower)
                is_inline_item_header = header_matches >= 2
                
                is_group_format = ("=" in full_line_text) or (full_line_text.startswith("[") and full_line_text.endswith("]"))
                
                # If any explicit format is matched (including inline headers), bypass has_grid_data completely
                is_block_header = is_inline_item_header or is_group_format or (
                    not has_grid_data 
                    and full_line_text.isupper() 
                    and len(full_line_text) > 2
                    and not is_continuation_candidate
                )

                if is_block_header:
                    if current_item:
                        clean_item = {k: v for k, v in current_item.items() if k != "_anchor_x0"}
                        block_node = next((n for n in data_tree if n["name"] == current_block_name), None)
                        if not block_node:
                            block_node = {"name": current_block_name, "type": "view", "items": []}
                            data_tree.append(block_node)
                        block_node["items"].append(clean_item)
                        current_item = None
                    # Use full_line_text here too
                    current_group_name = full_line_text.replace("=", "").replace("-", "").replace("[", "").replace("]", "").strip()
                    continue



                grid_dict = {}
                for i, col in enumerate(columns):
                    if i == desc_index: continue
                    val = " ".join(col_bins[i]).strip()
                    if val: grid_dict[col["label"]] = val




                # ---------------------------------------------------------
                # NEW LOGIC: Tally-Inspired X0 Tracking & Regex Defense
                # ---------------------------------------------------------


                # Calculate exact x0 for indent matching
                desc_left_bound = dividers[desc_index - 1] if desc_index > 0 else 0
                desc_right_bound = dividers[desc_index] if desc_index < len(dividers) else 9999
                desc_words = [w for w in lw if desc_left_bound <= midpoint(w) < desc_right_bound]
                current_x0 = desc_words[0]["x0"] if desc_words else None
                
                has_sno_in_pre = bool(pre_desc_text and any(char.isdigit() for char in pre_desc_text))
                has_sno_in_text = bool(re.match(r'^\d+\s+', text_str)) 
                
                is_new_item = has_sno_in_pre or has_sno_in_text
                is_batch_row = "Batch:" in text_str or "Batch:" in grid_dict.get("Item / Batch", "") or (not is_new_item and has_grid_data and current_item)

                if is_new_item or (not current_item and text_str and not is_batch_row):
                    # Save previous item, stripping the internal _anchor_x0 key
                    if current_item:
                        clean_item = {k: v for k, v in current_item.items() if k != "_anchor_x0"}
                        block_node = next((n for n in data_tree if n["name"] == current_block_name), None)
                        if not block_node:
                            block_node = {"name": current_block_name, "type": "view", "items": []}
                            data_tree.append(block_node)
                        block_node["items"].append(clean_item)
                    
                    clean_sno = pre_desc_text
                    clean_text = text_str
                    # if has_sno_in_text and not has_sno_in_pre:
                    #     match = re.match(r'^(\d+)\s+(.*)', text_str)
                    #     if match:
                    #         clean_sno = match.group(1)
                    #         clean_text = match.group(2)

                    if has_sno_in_text and not has_sno_in_pre:
                        match = re.match(r'^(\d+)\s+(.*)', text_str)
                        if match:
                            clean_sno = match.group(1)
                            clean_text = match.group(2)
                    
                    if current_group_name:
                        grid_dict["Group"] = current_group_name
                        

                

                    # Inside the 'is_new_item' initialization block
                    current_item = {
                        "S.No.": clean_sno, 
                        "particulars": clean_text, 
                        "data": grid_dict, 
                        "batches": [],
                        "_anchor_x0": current_x0  # Store for multi-line check
                    }
                    
                elif is_batch_row and current_item:
                    batch_data = {"Batch_Detail": text_str}
                    batch_data.update(grid_dict)
                    current_item["batches"].append(batch_data)
                    

                else:
                    # Multi-line item description continuation
                    if current_item:
                        # If there are no financials on this line, accept it as text continuation regardless of indent
                        if not has_grid_data and text_str:
                            current_item["particulars"] = (current_item["particulars"] + " " + text_str).strip()
                        # Otherwise, fall back to the strict Tally-like cluster tolerance
                        elif current_x0 is not None and current_item.get("_anchor_x0") is not None:
                            if abs(current_x0 - current_item["_anchor_x0"]) <= 15.0: # relaxed slightly to 15
                                if text_str:
                                    current_item["particulars"] = (current_item["particulars"] + " " + text_str).strip()
                                if grid_dict: 
                                    current_item["data"].update(grid_dict)

            # Commit the final item on the page
            if current_item:
                clean_item = {k: v for k, v in current_item.items() if k != "_anchor_x0"}
                block_node = next((n for n in data_tree if n["name"] == current_block_name), None)
                if not block_node:
                    block_node = {"name": current_block_name, "type": "view", "items": []}
                    data_tree.append(block_node)
                block_node["items"].append(clean_item)

    return data_tree


                


def parse_marg_spreadsheet(df: pd.DataFrame) -> list:
    """
    Parses a raw Marg Excel/CSV DataFrame into the Universal JSON Tree structure.
    """
    # 1. Identify the actual table header row
    # Marg exports often have 3-5 rows of metadata at the top before the columns start.
    header_row_index = -1
    # target_headers = {'description', 'particulars', 'item', 'product', 'qty', 'rate', 'mrp'}


    # Scan the first 20 rows to find where the actual table begins
    for idx, row in df.head(20).iterrows():
        row_str_set = set(str(val).lower().strip() for val in row.values if pd.notna(val))
        # Use the master MARG_HEADERS to catch any stock report variation
        if len(MARG_HEADERS.intersection(row_str_set)) >= 2:
            header_row_index = idx
            break

    if header_row_index == -1:
        return []  # Could not find valid table headers

    # 2. Reshape the DataFrame
    # Set the found row as the column headers and drop everything above it
    df.columns = df.iloc[header_row_index].astype(str).str.strip()
    df = df.iloc[header_row_index + 1:].reset_index(drop=True)

    # Drop completely empty columns and "Unnamed" columns
    df = df.loc[
        :, df.columns.notna() & (df.columns != '') & (~df.columns.str.contains('Unnamed', case=False, na=False))]



    # Identify the primary descriptive column
    desc_col = None
    
    # Priority 1: True Item Description (Use substring match to catch 'Item Name')
    for col in df.columns:
        col_lower = str(col).lower()
        if any(k in col_lower for k in ['description', 'particulars', 'item', 'product']):
            desc_col = col
            break
            
    # Priority 2: Groupings (Fallback only if an item column doesn't exist)
    if not desc_col:
        for col in df.columns:
            col_lower = str(col).lower()
            if any(k in col_lower for k in ['company', 'salt', 'category', 'store', 'godown', 'supplier']):
                desc_col = col
                break
                
    if not desc_col and len(df.columns) > 0:
        desc_col = df.columns[0]

    if not desc_col:
        return []



    # 3. Parse Data into the JSON Tree Structure
    data_tree = []
    current_block_name = "Default View"
    current_items = []
    current_item = None # Track the active parent item

    for _, row in df.iterrows():
        desc_val = row[desc_col]
        desc_str = str(desc_val).strip() if pd.notna(desc_val) else ""
        desc_lower = desc_str.lower()

        # 1. GLOBAL TOTAL CHECK
        # Check if the word "total" or "grand" appears ANYWHERE in this row
        is_total_row = any(
            isinstance(val, str) and ("total" in val.lower() or "grand" in val.lower() or "page" in val.lower()) 
            for val in row.values if pd.notna(val)
        )
        
        if is_total_row:
            continue # Safely skip summary lines, even if they are shifted to weird columns




        # Extract row data and check for financials
        has_financials = False
        row_data = {}
        for col in df.columns:
            val = row[col]
            if pd.notna(val) and str(val).strip() != '':
                row_data[col] = str(val).strip()
                # THE FIX: Only flag financials if digits appear in columns OTHER than the description
                if col != desc_col and any(char.isdigit() for char in str(val)):
                    has_financials = True

        # Skip completely empty rows
        if not desc_str and not has_financials:
            continue

        # Is it a Group Header? (e.g., [Store: Main] or === CIPLA ===)
        is_only_desc = len(row_data) == 1 and desc_col in row_data
        is_block_header = (not has_financials and is_only_desc) or (not has_financials and desc_str.isupper()) or (desc_str.startswith("[") and desc_str.endswith("]"))

        
        if is_block_header:
            if current_item:
                current_items.append(current_item)
                current_item = None
                
            if current_items:
                data_tree.append({"name": current_block_name, "type": "view", "items": current_items})
                current_items = [] 
            
            # Clean up formatting
            current_block_name = desc_str.replace("=", "").replace("-", "").replace("[", "").replace("]", "").strip()
            
        else:
            # Data Row Routing
            if desc_str:
                # Normal Item Row
                if current_item:
                    current_items.append(current_item)
                    
                current_item = {
                    "particulars": desc_str,
                    "data": row_data,
                    "batches": [] # Initialize for potential children
                }
            elif has_financials:
                # 2. BATCH KEY CHECK
                # Missing description, but has numbers. Is it truly a batch?
                batch_col = next((k for k in row_data.keys() if 'batch' in k.lower()), None)
                has_batch_val = batch_col and row_data.get(batch_col)

                if current_item and (has_batch_val or len(row_data) >= 2):
                    current_item["batches"].append(row_data)
                elif not current_item:
                    # 3. ORPHAN CHECK
                    # We found numbers, but no parent item exists yet.
                    current_items.append({"particulars": "Unknown Data Row", "data": row_data, "batches": []})

    # Commit final items
    if current_item:
        current_items.append(current_item)
        
    if current_items:
        data_tree.append({
            "name": current_block_name,
            "type": "view",
            "items": current_items
        })

    return data_tree

    

def midpoint(w: dict) -> float:
    return (w["x0"] + w["x1"]) / 2.0







# --- ADD THIS NEW CONSTANT ---
# The Ultimate Marg Order Vocabulary List
MARG_ORDER_HEADERS = {
    # Identifiers
    "s.no.", "s.no", "order", "ord.no", "bill", "date", "status", "due",
    # Entities / Groupings
    "party", "customer", "ledger", "item", "item name", "product", "description", "particulars", 
    "supplier", "m.r.", "salesman", "station", "route", "area", "agency", "company",
    # Quantities & Fulfillment
    "qty", "quantity", "ord", "ord qty", "ord.qty", "clear", "supplied", "sup.qty", "pending", "pend.qty", "bal.qty", "shortage", "stock", "pack", "free",
    # Financials
    "rate", "p.rate", "amount", "value", "net", "gross", "basic", "discount", "dis%", 
    "tax", "gst", "cgst", "sgst", "igst", "balance", "mrp", "ptr"
}

# --- ADD THIS NEW FUNCTION ---
def parse_marg_order_pdf(pdf_path: str) -> list:
    """
    Parses Marg Order Reports from PDF using spatial geometry.
    Handles Party-wise, Item-wise, and flat Order Registers.
    """
    data_tree = []
    
    with pdfplumber.open(pdf_path) as pdf:
        for page_num, page in enumerate(pdf.pages):
            words = page.extract_words(keep_blank_chars=False)
            if not words: continue

            # 1. Find the header row based on Order vocabulary
            y_buckets = {}
            for w in words:
                y_b = round(w["top"] / 4) * 4
                if w["text"].lower() in MARG_ORDER_HEADERS:
                    y_buckets.setdefault(y_b, []).append(w)

            if not y_buckets: continue
            best_y = max(y_buckets.keys(), key=lambda y: len(y_buckets[y]))
            if len(y_buckets[best_y]) < 2: continue 

            header_top = min([w["top"] for w in y_buckets[best_y]])


            # # 2. Build Columns



            anchor_words = [w for w in words if abs(w["top"] - header_top) < 15 and w["text"].lower() in MARG_ORDER_HEADERS]
            anchor_words = [w for w in anchor_words if not re.match(r'^[-=]+$', w["text"])]
            if not anchor_words: continue
            anchor_words.sort(key=lambda w: midpoint(w))

            # 2. Build Columns with Smart Split
            columns = []
            current_label = {"text": anchor_words[0]["text"], "x0": anchor_words[0]["x0"], "x1": anchor_words[0]["x1"]}
            for w in anchor_words[1:]:
                gap = w["x0"] - current_label["x1"]
                
                # Order-specific standalone columns
                # Order-specific standalone columns
                is_standalone_col = w["text"].lower() in {"qty", "ord", "clear", "pending", "amount", "value", "rate", "net", "tax", "gst", "cgst", "sgst", "basic", "pack", "balance", "mrp", "shortage", "free", "sup.qty", "pend.qty", "ord.qty"}
                
                if gap < 4 or (gap < 10 and not is_standalone_col):
                    current_label["text"] += " " + w["text"]
                    current_label["x1"] = w["x1"]
                else:
                    columns.append({"label": current_label["text"].title(), "center": (current_label["x0"] + current_label["x1"]) / 2.0, "x0": current_label["x0"]})
                    current_label = {"text": w["text"], "x0": w["x0"], "x1": w["x1"]}
            columns.append({"label": current_label["text"].title(), "center": (current_label["x0"] + current_label["x1"]) / 2.0, "x0": current_label["x0"]})



            # Find the primary descriptive column (Party, Item, Route, MR, etc.)
            desc_index = 0
            target_words = ["party", "customer", "item", "product", "description", "particulars", "m.r.", "salesman", "station", "route", "area", "agency", "company"]
            for i, col in enumerate(columns):
                if any(word in col["label"].lower() for word in target_words):
                    desc_index = i
                    break
            
            # 3. Calculate Dividers
            dividers = []
            for i in range(desc_index): dividers.append((columns[i]["center"] + columns[i+1]["center"]) / 2.0)
            if desc_index + 1 < len(columns): dividers.append(columns[desc_index + 1]["x0"] - 15)
            for i in range(desc_index + 1, len(columns) - 1): dividers.append((columns[i]["center"] + columns[i+1]["center"]) / 2.0)
            if not dividers: dividers = [9999]


            # 4. Group words into lines
            lines = {}
            for w in words:
                if w["top"] < header_top + 10: continue
                y = round(w["top"] / 3) * 3
                lines.setdefault(y, []).append(w)

            # Filter out purely decorative lines AFTER grouping
            clean_lines = {}
            for y, lw in lines.items():
                line_text_no_spaces = "".join(w["text"] for w in lw).replace(" ", "")
                if not re.match(r'^[-=]+$', line_text_no_spaces):
                    clean_lines[y] = lw
            lines = clean_lines



            current_block_name = f"Order View {page_num + 1}"
            current_group_name = None
            current_item = None

            # THE FIX: Dynamically generate inline header traps based on the PDF's actual columns
            # We add a colon ":" to match Marg's inline format (e.g., "Pack:", "Mrp:")
            dynamic_inline_keys = [col["label"].lower() + ":" for col in columns if len(col["label"]) > 2]

            # 5. Extract Data
            for y in sorted(lines.keys()):
                lw = sorted(lines[y], key=lambda w: w["x0"])
                if not lw: continue
                if marg_is_stop_line(lw): break

                # Check if this line has numeric financial data
                main_divider = dividers[desc_index] if len(dividers) > desc_index else dividers[0]
                fin_words = [w["text"] for w in lw if midpoint(w) >= main_divider]
                has_grid_data = any(any(char.isdigit() or char == '-' for char in text) for text in fin_words)

                # Bin words into columns
                col_bins = [[] for _ in columns] 
                for w in lw:
                    cx = midpoint(w)
                    placed = False
                    for i in range(len(dividers)):
                        if cx < dividers[i]:
                            col_bins[i].append(w["text"])
                            placed = True
                            break
                    if not placed:
                        col_bins[-1].append(w["text"])

                
                text_str = " ".join(col_bins[desc_index]).strip()
                pre_desc_text = "".join([" ".join(col_bins[i]).strip() for i in range(desc_index)])

                if "total:" in text_str.lower() or "total :" in text_str.lower() or "grand total" in text_str.lower():
                    if current_item:
                        clean_item = {k: v for k, v in current_item.items() if k != "_anchor_x0"}
                        block_node = next((n for n in data_tree if n["name"] == current_block_name), None)
                        if not block_node:
                            block_node = {"name": current_block_name, "type": "order_group", "items": []}
                            data_tree.append(block_node)
                        block_node["items"].append(clean_item)
                        current_item = None
                    continue

                desc_left_bound = dividers[desc_index - 1] if desc_index > 0 else 0
                desc_right_bound = dividers[desc_index] if desc_index < len(dividers) else 9999
                desc_words = [w for w in lw if desc_left_bound <= midpoint(w) < desc_right_bound]
                current_x0 = desc_words[0]["x0"] if desc_words else None

                is_continuation_candidate = (
                    current_item is not None
                    and current_x0 is not None
                    and current_item.get("_anchor_x0") is not None
                    and abs(current_x0 - current_item["_anchor_x0"]) <= 10
                )
                

                full_line_text = " ".join(w["text"] for w in lw).strip()
                full_line_lower = full_line_text.lower()
                
                # THE FIX: Explicit Keyword Overrides
                is_party_header = full_line_lower.startswith("party:") or full_line_lower.startswith("party ") or full_line_lower.startswith("supplier:")
                
                # THE FIX: Dynamically check if the line contains at least 2 active column headers
                header_matches = sum(1 for key in dynamic_inline_keys if key in full_line_lower)
                is_inline_item_header = header_matches >= 2
                
                is_group_format = ("=" in full_line_text) or (full_line_text.startswith("[") and full_line_text.endswith("]"))
                
                # If any explicit format is matched, bypass has_grid_data completely
                is_block_header = is_party_header or is_inline_item_header or is_group_format or (
                    not has_grid_data 
                    and full_line_text.isupper() 
                    and len(full_line_text) > 2
                    and not is_continuation_candidate
                )

                if is_block_header:
                    if current_item:
                        clean_item = {k: v for k, v in current_item.items() if k != "_anchor_x0"}
                        block_node = next((n for n in data_tree if n["name"] == current_block_name), None)
                        if not block_node:
                            block_node = {"name": current_block_name, "type": "order_group", "items": []}
                            data_tree.append(block_node)
                        block_node["items"].append(clean_item)
                        current_item = None
                    current_group_name = full_line_text.replace("=", "").replace("-", "").replace("[", "").replace("]", "").strip()
                    continue

                grid_dict = {}
                for i, col in enumerate(columns):
                    if i == desc_index: continue
                    val = " ".join(col_bins[i]).strip()
                    if val: grid_dict[col["label"]] = val

                starts_new_item = False
                if pre_desc_text: 
                    starts_new_item = True
                elif has_grid_data and current_item and any(k in current_item["data"] for k in grid_dict.keys()):
                    starts_new_item = True
                elif not current_item and (text_str or has_grid_data):
                    starts_new_item = True

                if starts_new_item:
                    if current_item:
                        clean_item = {k: v for k, v in current_item.items() if k != "_anchor_x0"}
                        block_node = next((n for n in data_tree if n["name"] == current_block_name), None)
                        if not block_node:
                            block_node = {"name": current_block_name, "type": "order_group", "items": []}
                            data_tree.append(block_node)
                        block_node["items"].append(clean_item)
                        
                    if current_group_name:
                        grid_dict["Group"] = current_group_name
                    current_item = {"particulars": text_str, "data": grid_dict, "_anchor_x0": current_x0}
                else:
                    if current_item:
                        if not has_grid_data and text_str:
                            current_item["particulars"] = (current_item["particulars"] + " " + text_str).strip()
                        elif current_x0 is not None and current_item.get("_anchor_x0") is not None:
                            if abs(current_x0 - current_item["_anchor_x0"]) <= 15.0:
                                if text_str:
                                    current_item["particulars"] = (current_item["particulars"] + " " + text_str).strip()
                                if grid_dict: 
                                    current_item["data"].update(grid_dict)

            # Commit the final item on the page
            if current_item:
                block_node = next((n for n in data_tree if n["name"] == current_block_name), None)
                if not block_node:
                    block_node = {"name": current_block_name, "type": "order_group", "items": []}
                    data_tree.append(block_node)
                # block_node["items"].append(current_item)
                clean_item = {k: v for k, v in current_item.items() if k != "_anchor_x0"}
                block_node["items"].append(clean_item)

    return data_tree




def parse_marg_order_spreadsheet(df: pd.DataFrame) -> list:
    """
    Parses Marg Order Reports (Party-wise, Item-wise, or Flat Register) into JSON.
    """
    # 1. Identify the actual table header row
    header_row_index = -1
    
    
    for idx, row in df.head(20).iterrows():
        row_str_set = set(str(val).lower().strip() for val in row.values if pd.notna(val))
        
        # If we find at least 2 common Order headers, this is our row
        if len(MARG_ORDER_HEADERS.intersection(row_str_set)) >= 2: 
            header_row_index = idx
            break
            
    if header_row_index == -1:
        return [] 
        
    # 2. Reshape the DataFrame
    df.columns = df.iloc[header_row_index].astype(str).str.strip()
    df = df.iloc[header_row_index + 1:].reset_index(drop=True)
    df = df.loc[:, df.columns.notna() & (df.columns != '') & (~df.columns.str.contains('Unnamed', case=False, na=False))]
    



    # 3. Identify the primary descriptive column
    desc_col = None
    
    # Priority 1: True Item/Order Description 
    for col in df.columns:
        col_lower = str(col).lower()
        if any(k in col_lower for k in ['item', 'product', 'description', 'particulars']):
            desc_col = col
            break
            
    # Priority 2: Groupings / Parties (Fallback)
    if not desc_col:
        for col in df.columns:
            col_lower = str(col).lower()
            if any(k in col_lower for k in ['party', 'customer', 'party name', 'm.r.', 'salesman', 'station', 'route', 'area', 'agency', 'company']):
                desc_col = col
                break
            
    # Fallback if specific columns aren't found, just grab the first column
    if not desc_col and len(df.columns) > 0:
        desc_col = df.columns[0]

    # 4. Parse Data into the JSON Tree Structure
    data_tree = []
    current_block_name = "Order Register View" # Default name for flat files
    current_items = []
    
    for _, row in df.iterrows():
        desc_val = row[desc_col]
        
        if pd.isna(desc_val) or str(desc_val).strip() == '':
            continue
            
        desc_str = str(desc_val).strip()
        desc_lower = desc_str.lower()
        
        if "total" in desc_lower or "page" in desc_lower or "grand" in desc_lower:
            continue
            
        has_financials = False
        row_data = {}
        

        # Check ALL columns for data (including desc_col)
        for col in df.columns:
            val = row[col]
            if pd.notna(val) and str(val).strip() != '':
                row_data[col] = str(val).strip()
                # THE FIX: Only flag financials if digits appear in columns OTHER than the description
                if col != desc_col and any(char.isdigit() for char in str(val)):
                    has_financials = True
                        
        # Routing Logic: Is it a Group Header (e.g. === PARTY NAME ===) or an Item?
        is_only_desc = len(row_data) == 1 and desc_col in row_data
        # THE FIX: If it's the only column populated, it's definitively a group header, even if the drug name has numbers (e.g. 500MG)
        is_block_header = is_only_desc or (not has_financials and desc_str.isupper()) or (desc_str.startswith("[") and desc_str.endswith("]"))


        
        if is_block_header:
            if current_items:
                data_tree.append({"name": current_block_name, "type": "order_group", "items": current_items})
                current_items = [] 
            
            # Clean up Marg's formatting
            current_block_name = desc_str.replace("=", "").replace("-", "").strip()
        else:
            current_items.append({"particulars": desc_str, "data": row_data})
            
    if current_items:
        data_tree.append({"name": current_block_name, "type": "order_group", "items": current_items})
        
    return data_tree