stock_report_robust / parser /marg_parser.py
iammraat's picture
Update parser/marg_parser.py
13dbdea verified
Raw
History Blame Contribute Delete
36.9 kB
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