Spaces:
Running
Running
| 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 |