light-infer-chat commited on
Commit
932b2b4
·
1 Parent(s): 0703216
app/api/v1/reconcile.py CHANGED
@@ -8,7 +8,7 @@ import uuid
8
  from typing import Any, Dict, List, Optional
9
 
10
  import aiohttp
11
- from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
12
  from pydantic import BaseModel
13
 
14
  from app.api.deps import require_auth
@@ -18,14 +18,15 @@ from app.services.reconciliation_service import (
18
  SUPPORTED_EXTENSIONS,
19
  analyze_duplicates,
20
  analyze_missing_data,
 
21
  compare_rows,
22
  compare_schemas,
23
  download_file_with_retry,
24
  normalize_dataframe,
 
25
  reconcile_columns,
26
  reconcile_date_columns,
27
  reconcile_numeric_columns,
28
- read_to_dataframe,
29
  )
30
 
31
  router = APIRouter()
@@ -36,14 +37,19 @@ _MAX_PAIRS = 10
36
  semaphore_jobs = asyncio.Semaphore(5)
37
 
38
 
 
 
 
 
 
39
  class ReconciliationPair(BaseModel):
40
  source: str
41
  destination: str
 
42
 
43
 
44
  class ReconciliationUrlRequest(BaseModel):
45
  pairs: List[ReconciliationPair]
46
- numeric_columns: Optional[List[str]] = None
47
 
48
 
49
  class ReconciliationPairResult(BaseModel):
@@ -85,12 +91,6 @@ def _failed_result(pair_index: int, error: str) -> Dict[str, Any]:
85
  }
86
 
87
 
88
- def _parse_numeric_keywords(numeric_columns: Optional[List[str]]) -> Optional[set]:
89
- if not numeric_columns:
90
- return None
91
- return set(c.lower() for c in numeric_columns)
92
-
93
-
94
  def _validate_extensions(src_ext: str, dst_ext: str, idx: int) -> None:
95
  if src_ext not in SUPPORTED_EXTENSIONS:
96
  raise HTTPException(status_code=400, detail={"success": False, "message": f"Pair {idx}: Unsupported source format '{src_ext}'."})
@@ -112,12 +112,30 @@ def _build_response(results: List[Dict[str, Any]], start_time: float) -> Reconci
112
  )
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  async def _process_file_pair(
116
  source_file: bytes,
117
  dest_file: bytes,
118
  source_ext: str,
119
  dest_ext: str,
120
- numeric_keywords: Optional[set] = None,
121
  pair_index: int = 0,
122
  ) -> Dict[str, Any]:
123
  if source_ext != dest_ext:
@@ -132,8 +150,11 @@ async def _process_file_pair(
132
 
133
  df_src = normalize_dataframe(df_src.copy())
134
  df_dst = normalize_dataframe(df_dst.copy())
135
- df_src.infer_objects()
136
- df_dst.infer_objects()
 
 
 
137
 
138
  schema_report = compare_schemas(df_src, df_dst)
139
  status = "MATCH" if schema_report["fully_match"] else "SCHEMA_MISMATCH"
@@ -145,7 +166,7 @@ async def _process_file_pair(
145
  status = "PARTIAL_MATCH"
146
 
147
  col_reports = reconcile_columns(df_src, df_dst, common_cols)
148
- num_reports = reconcile_numeric_columns(df_src, df_dst, common_cols, numeric_keywords)
149
  date_reports = reconcile_date_columns(df_src, df_dst, common_cols)
150
  dup_report = analyze_duplicates(df_src, df_dst)
151
  missing_report = analyze_missing_data(df_src, df_dst, common_cols)
@@ -172,47 +193,55 @@ async def _process_file_pair(
172
  summary="Reconcile uploaded file pairs (up to 10 pairs)",
173
  )
174
  async def reconcile_files(
175
- source_1: UploadFile = File(None, description="Source file for pair 1"),
176
- destination_1: UploadFile = File(None, description="Destination file for pair 1"),
177
- source_2: UploadFile = File(None, description="Source file for pair 2"),
178
- destination_2: UploadFile = File(None, description="Destination file for pair 2"),
179
- source_3: UploadFile = File(None, description="Source file for pair 3"),
180
- destination_3: UploadFile = File(None, description="Destination file for pair 3"),
181
- source_4: UploadFile = File(None, description="Source file for pair 4"),
182
- destination_4: UploadFile = File(None, description="Destination file for pair 4"),
183
- source_5: UploadFile = File(None, description="Source file for pair 5"),
184
- destination_5: UploadFile = File(None, description="Destination file for pair 5"),
185
- source_6: UploadFile = File(None, description="Source file for pair 6"),
186
- destination_6: UploadFile = File(None, description="Destination file for pair 6"),
187
- source_7: UploadFile = File(None, description="Source file for pair 7"),
188
- destination_7: UploadFile = File(None, description="Destination file for pair 7"),
189
- source_8: UploadFile = File(None, description="Source file for pair 8"),
190
- destination_8: UploadFile = File(None, description="Destination file for pair 8"),
191
- source_9: UploadFile = File(None, description="Source file for pair 9"),
192
- destination_9: UploadFile = File(None, description="Destination file for pair 9"),
193
- source_10: UploadFile = File(None, description="Source file for pair 10"),
194
- destination_10: UploadFile = File(None, description="Destination file for pair 10"),
195
- numeric_columns: Optional[str] = None,
 
 
 
 
 
 
 
 
 
196
  token: str = Depends(require_auth),
197
  ) -> ReconciliationResponse:
198
  start_time = time.time()
199
 
200
- numeric_keywords = None
201
- if numeric_columns:
202
- try:
203
- numeric_keywords = _parse_numeric_keywords(json.loads(numeric_columns))
204
- except json.JSONDecodeError:
205
- pass
206
-
207
  files = [
208
- (source_1, destination_1), (source_2, destination_2), (source_3, destination_3),
209
- (source_4, destination_4), (source_5, destination_5), (source_6, destination_6),
210
- (source_7, destination_7), (source_8, destination_8), (source_9, destination_9),
211
- (source_10, destination_10),
 
 
 
 
 
 
212
  ]
213
 
214
  pairs: List[tuple] = []
215
- for idx, (src, dst) in enumerate(files, 1):
216
  if src is None and dst is None:
217
  continue
218
  if src is None or dst is None:
@@ -220,18 +249,19 @@ async def reconcile_files(
220
  src_ext = _extract_extension(src.filename or "")
221
  dst_ext = _extract_extension(dst.filename or "")
222
  _validate_extensions(src_ext, dst_ext, idx)
223
- pairs.append((src, dst, idx, src_ext, dst_ext))
224
 
225
  if not pairs:
226
  raise HTTPException(status_code=400, detail={"success": False, "message": "At least one file pair is required."})
227
 
228
- async def process_file_pair(src: UploadFile, dst: UploadFile, idx: int, src_ext: str, dst_ext: str) -> Dict[str, Any]:
229
  async with semaphore_jobs:
230
  src_data = await src.read()
231
  dst_data = await dst.read()
232
  if len(src_data) > _MAX_UPLOAD_BYTES or len(dst_data) > _MAX_UPLOAD_BYTES:
233
  return _failed_result(idx, "File size exceeds maximum allowed limit")
234
- return await _process_file_pair(src_data, dst_data, src_ext, dst_ext, numeric_keywords, idx)
 
235
 
236
  results = await asyncio.gather(*[process_file_pair(*p) for p in pairs])
237
  return _build_response(results, start_time)
@@ -256,27 +286,26 @@ async def reconcile_urls(
256
  for idx, pair in enumerate(body.pairs, 1):
257
  _validate_extensions(_extract_extension(pair.source), _extract_extension(pair.destination), idx)
258
 
259
- numeric_keywords = _parse_numeric_keywords(body.numeric_columns)
 
 
 
 
260
 
261
- async def process_url_pair(pair: ReconciliationPair, idx: int) -> Dict[str, Any]:
262
- async with semaphore_jobs:
263
- job_id = str(uuid.uuid4())
264
- src_ext = _extract_extension(pair.source)
265
- dst_ext = _extract_extension(pair.destination)
266
-
267
- async with aiohttp.ClientSession() as session:
268
  try:
269
  src_data, dst_data = await asyncio.gather(
270
- download_file_with_retry(session, pair.source, job_id),
271
- download_file_with_retry(session, pair.destination, job_id),
272
  )
273
  except Exception as e:
274
  return _failed_result(idx, str(e))
275
 
276
- if len(src_data) > _MAX_UPLOAD_BYTES or len(dst_data) > _MAX_UPLOAD_BYTES:
277
- return _failed_result(idx, "File size exceeds maximum allowed limit")
 
 
 
278
 
279
- return await _process_file_pair(src_data, dst_data, src_ext, dst_ext, numeric_keywords, idx)
280
 
281
- results = await asyncio.gather(*[process_url_pair(pair, idx) for idx, pair in enumerate(body.pairs, 1)])
282
  return _build_response(results, start_time)
 
8
  from typing import Any, Dict, List, Optional
9
 
10
  import aiohttp
11
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
12
  from pydantic import BaseModel
13
 
14
  from app.api.deps import require_auth
 
18
  SUPPORTED_EXTENSIONS,
19
  analyze_duplicates,
20
  analyze_missing_data,
21
+ apply_column_mapping,
22
  compare_rows,
23
  compare_schemas,
24
  download_file_with_retry,
25
  normalize_dataframe,
26
+ read_to_dataframe,
27
  reconcile_columns,
28
  reconcile_date_columns,
29
  reconcile_numeric_columns,
 
30
  )
31
 
32
  router = APIRouter()
 
37
  semaphore_jobs = asyncio.Semaphore(5)
38
 
39
 
40
+ class ColumnMapping(BaseModel):
41
+ source: str
42
+ destination: str
43
+
44
+
45
  class ReconciliationPair(BaseModel):
46
  source: str
47
  destination: str
48
+ column_mapping: Optional[List[ColumnMapping]] = None
49
 
50
 
51
  class ReconciliationUrlRequest(BaseModel):
52
  pairs: List[ReconciliationPair]
 
53
 
54
 
55
  class ReconciliationPairResult(BaseModel):
 
91
  }
92
 
93
 
 
 
 
 
 
 
94
  def _validate_extensions(src_ext: str, dst_ext: str, idx: int) -> None:
95
  if src_ext not in SUPPORTED_EXTENSIONS:
96
  raise HTTPException(status_code=400, detail={"success": False, "message": f"Pair {idx}: Unsupported source format '{src_ext}'."})
 
112
  )
113
 
114
 
115
+ def _parse_column_mapping_str(value: Optional[str]) -> Optional[Dict[str, str]]:
116
+ if not value:
117
+ return None
118
+ try:
119
+ parsed = json.loads(value)
120
+ if isinstance(parsed, list):
121
+ return {item["destination"]: item["source"] for item in parsed if "source" in item and "destination" in item}
122
+ except (json.JSONDecodeError, KeyError, TypeError):
123
+ pass
124
+ return None
125
+
126
+
127
+ def _parse_column_mapping_model(mappings: Optional[List[ColumnMapping]]) -> Optional[Dict[str, str]]:
128
+ if not mappings:
129
+ return None
130
+ return {m.destination: m.source for m in mappings}
131
+
132
+
133
  async def _process_file_pair(
134
  source_file: bytes,
135
  dest_file: bytes,
136
  source_ext: str,
137
  dest_ext: str,
138
+ column_mapping: Optional[Dict[str, str]] = None,
139
  pair_index: int = 0,
140
  ) -> Dict[str, Any]:
141
  if source_ext != dest_ext:
 
150
 
151
  df_src = normalize_dataframe(df_src.copy())
152
  df_dst = normalize_dataframe(df_dst.copy())
153
+ df_src = df_src.infer_objects()
154
+ df_dst = df_dst.infer_objects()
155
+
156
+ if column_mapping:
157
+ df_dst = apply_column_mapping(df_dst, column_mapping)
158
 
159
  schema_report = compare_schemas(df_src, df_dst)
160
  status = "MATCH" if schema_report["fully_match"] else "SCHEMA_MISMATCH"
 
166
  status = "PARTIAL_MATCH"
167
 
168
  col_reports = reconcile_columns(df_src, df_dst, common_cols)
169
+ num_reports = reconcile_numeric_columns(df_src, df_dst, common_cols)
170
  date_reports = reconcile_date_columns(df_src, df_dst, common_cols)
171
  dup_report = analyze_duplicates(df_src, df_dst)
172
  missing_report = analyze_missing_data(df_src, df_dst, common_cols)
 
193
  summary="Reconcile uploaded file pairs (up to 10 pairs)",
194
  )
195
  async def reconcile_files(
196
+ source_1: UploadFile = File(None),
197
+ destination_1: UploadFile = File(None),
198
+ column_mapping_1: Optional[str] = Form(None),
199
+ source_2: UploadFile = File(None),
200
+ destination_2: UploadFile = File(None),
201
+ column_mapping_2: Optional[str] = Form(None),
202
+ source_3: UploadFile = File(None),
203
+ destination_3: UploadFile = File(None),
204
+ column_mapping_3: Optional[str] = Form(None),
205
+ source_4: UploadFile = File(None),
206
+ destination_4: UploadFile = File(None),
207
+ column_mapping_4: Optional[str] = Form(None),
208
+ source_5: UploadFile = File(None),
209
+ destination_5: UploadFile = File(None),
210
+ column_mapping_5: Optional[str] = Form(None),
211
+ source_6: UploadFile = File(None),
212
+ destination_6: UploadFile = File(None),
213
+ column_mapping_6: Optional[str] = Form(None),
214
+ source_7: UploadFile = File(None),
215
+ destination_7: UploadFile = File(None),
216
+ column_mapping_7: Optional[str] = Form(None),
217
+ source_8: UploadFile = File(None),
218
+ destination_8: UploadFile = File(None),
219
+ column_mapping_8: Optional[str] = Form(None),
220
+ source_9: UploadFile = File(None),
221
+ destination_9: UploadFile = File(None),
222
+ column_mapping_9: Optional[str] = Form(None),
223
+ source_10: UploadFile = File(None),
224
+ destination_10: UploadFile = File(None),
225
+ column_mapping_10: Optional[str] = Form(None),
226
  token: str = Depends(require_auth),
227
  ) -> ReconciliationResponse:
228
  start_time = time.time()
229
 
 
 
 
 
 
 
 
230
  files = [
231
+ (source_1, destination_1, column_mapping_1),
232
+ (source_2, destination_2, column_mapping_2),
233
+ (source_3, destination_3, column_mapping_3),
234
+ (source_4, destination_4, column_mapping_4),
235
+ (source_5, destination_5, column_mapping_5),
236
+ (source_6, destination_6, column_mapping_6),
237
+ (source_7, destination_7, column_mapping_7),
238
+ (source_8, destination_8, column_mapping_8),
239
+ (source_9, destination_9, column_mapping_9),
240
+ (source_10, destination_10, column_mapping_10),
241
  ]
242
 
243
  pairs: List[tuple] = []
244
+ for idx, (src, dst, cm) in enumerate(files, 1):
245
  if src is None and dst is None:
246
  continue
247
  if src is None or dst is None:
 
249
  src_ext = _extract_extension(src.filename or "")
250
  dst_ext = _extract_extension(dst.filename or "")
251
  _validate_extensions(src_ext, dst_ext, idx)
252
+ pairs.append((src, dst, cm, idx, src_ext, dst_ext))
253
 
254
  if not pairs:
255
  raise HTTPException(status_code=400, detail={"success": False, "message": "At least one file pair is required."})
256
 
257
+ async def process_file_pair(src: UploadFile, dst: UploadFile, cm_str: Optional[str], idx: int, src_ext: str, dst_ext: str) -> Dict[str, Any]:
258
  async with semaphore_jobs:
259
  src_data = await src.read()
260
  dst_data = await dst.read()
261
  if len(src_data) > _MAX_UPLOAD_BYTES or len(dst_data) > _MAX_UPLOAD_BYTES:
262
  return _failed_result(idx, "File size exceeds maximum allowed limit")
263
+ mapping = _parse_column_mapping_str(cm_str)
264
+ return await _process_file_pair(src_data, dst_data, src_ext, dst_ext, mapping, idx)
265
 
266
  results = await asyncio.gather(*[process_file_pair(*p) for p in pairs])
267
  return _build_response(results, start_time)
 
286
  for idx, pair in enumerate(body.pairs, 1):
287
  _validate_extensions(_extract_extension(pair.source), _extract_extension(pair.destination), idx)
288
 
289
+ async with aiohttp.ClientSession() as session:
290
+ async def process_url_pair(pair: ReconciliationPair, idx: int) -> Dict[str, Any]:
291
+ async with semaphore_jobs:
292
+ src_ext = _extract_extension(pair.source)
293
+ dst_ext = _extract_extension(pair.destination)
294
 
 
 
 
 
 
 
 
295
  try:
296
  src_data, dst_data = await asyncio.gather(
297
+ download_file_with_retry(session, pair.source, str(uuid.uuid4())),
298
+ download_file_with_retry(session, pair.destination, str(uuid.uuid4())),
299
  )
300
  except Exception as e:
301
  return _failed_result(idx, str(e))
302
 
303
+ if len(src_data) > _MAX_UPLOAD_BYTES or len(dst_data) > _MAX_UPLOAD_BYTES:
304
+ return _failed_result(idx, "File size exceeds maximum allowed limit")
305
+
306
+ mapping = _parse_column_mapping_model(pair.column_mapping)
307
+ return await _process_file_pair(src_data, dst_data, src_ext, dst_ext, mapping, idx)
308
 
309
+ results = await asyncio.gather(*[process_url_pair(pair, idx) for idx, pair in enumerate(body.pairs, 1)])
310
 
 
311
  return _build_response(results, start_time)
app/services/reconciliation_service.py CHANGED
@@ -60,6 +60,13 @@ def normalize_dataframe(df: pd.DataFrame) -> pd.DataFrame:
60
  return df
61
 
62
 
 
 
 
 
 
 
 
63
  async def download_file_with_retry(session: aiohttp.ClientSession, url: str, correlation_id: str) -> bytes:
64
  for attempt in range(DOWNLOAD_MAX_RETRIES):
65
  try:
@@ -68,7 +75,10 @@ async def download_file_with_retry(session: aiohttp.ClientSession, url: str, cor
68
  return await response.read()
69
  except aiohttp.ClientError as e:
70
  wait_time = DOWNLOAD_BACKOFF_FACTOR * (2 ** attempt)
71
- _logger.warning(f"Download attempt {attempt+1} failed for {url}. Retrying in {wait_time}s. Error: {e}", extra={"correlation_id": correlation_id})
 
 
 
72
  await asyncio.sleep(wait_time)
73
  raise DownloadError(f"Failed to download {url} after {DOWNLOAD_MAX_RETRIES} retries.")
74
 
@@ -252,53 +262,65 @@ def _build_failed_pair_result(job_id: str, src_url: str, dst_url: str, started_a
252
  }
253
 
254
 
255
- async def process_pair(src_url: str, dst_url: str, src_data: bytes, dst_data: bytes, ext: str, job_id: str, numeric_keywords: Optional[Set[str]] = None) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
256
  start_time = time.time()
257
  started_at = datetime.utcnow().isoformat()
258
  errors = []
259
  warnings = []
260
  status = "MATCH"
261
-
262
  try:
263
  df_src = read_to_dataframe(src_data, ext, job_id)
264
  df_dst = read_to_dataframe(dst_data, ext, job_id)
265
-
266
  if df_src.empty or df_dst.empty:
267
  raise EmptyDatasetError("One or both datasets are empty.")
268
-
269
  df_src = normalize_dataframe(df_src.copy())
270
  df_dst = normalize_dataframe(df_dst.copy())
271
  df_src.infer_objects()
272
  df_dst.infer_objects()
273
-
 
 
 
274
  schema_report = compare_schemas(df_src, df_dst)
275
  if not schema_report["fully_match"]:
276
  status = "SCHEMA_MISMATCH"
277
  warnings.append("Schema mismatch detected.")
278
-
279
  common_cols = df_src.columns.intersection(df_dst.columns).tolist()
280
-
281
  identical_rows, _, missing_rows, extra_rows = compare_rows(df_src, df_dst, common_cols)
282
-
283
  if missing_rows > 0 or extra_rows > 0:
284
  status = "PARTIAL_MATCH" if status == "MATCH" else status
285
-
286
  col_reports = reconcile_columns(df_src, df_dst, common_cols)
287
  num_reports = reconcile_numeric_columns(df_src, df_dst, common_cols, numeric_keywords)
288
  date_reports = reconcile_date_columns(df_src, df_dst, common_cols)
289
-
290
  dup_report = analyze_duplicates(df_src, df_dst)
291
  missing_report = analyze_missing_data(df_src, df_dst, common_cols)
292
-
293
  full_cols = sum(1 for c in col_reports if c["status"] == "fully_match")
294
  part_cols = sum(1 for c in col_reports if c["status"] == "partial_match")
295
-
296
  if part_cols > 0 and status == "MATCH":
297
  status = "PARTIAL_MATCH"
298
-
299
  total_max_rows = max(len(df_src), len(df_dst))
300
  match_pct = (identical_rows / total_max_rows * 100) if total_max_rows > 0 else 100.0
301
-
302
  summary = {
303
  "total_columns": len(common_cols),
304
  "fully_matched_columns": full_cols,
@@ -319,17 +341,14 @@ async def process_pair(src_url: str, dst_url: str, src_data: bytes, dst_data: by
319
  errors.append(str(e))
320
  summary = {"overall_status": "FAILED", "overall_match_percentage": 0.0}
321
  schema_report, col_reports, num_reports, date_reports, dup_report, missing_report = {}, [], [], [], {}, []
322
-
323
- completed_at = datetime.utcnow().isoformat()
324
- processing_time_ms = round((time.time() - start_time) * 1000, 2)
325
-
326
  return {
327
  "job_id": job_id,
328
  "source_file": src_url,
329
  "destination_file": dst_url,
330
  "started_at": started_at,
331
- "completed_at": completed_at,
332
- "processing_time_ms": processing_time_ms,
333
  "status": status,
334
  "summary": summary,
335
  "schema": schema_report,
@@ -343,27 +362,32 @@ async def process_pair(src_url: str, dst_url: str, src_data: bytes, dst_data: by
343
  }
344
 
345
 
346
- async def reconcile_pair(pair: Dict[str, str], job_id: str, numeric_keywords: Optional[Set[str]] = None) -> Dict[str, Any]:
 
 
 
 
347
  src_url = pair["source"]
348
  dst_url = pair["destination"]
349
-
350
  started_at = datetime.utcnow().isoformat()
351
-
352
  src_ext = extract_extension(src_url)
353
  dst_ext = extract_extension(dst_url)
354
-
355
  if src_ext not in SUPPORTED_EXTENSIONS or dst_ext not in SUPPORTED_EXTENSIONS:
356
  return _build_failed_pair_result(
357
  job_id, src_url, dst_url, started_at, "FAILED",
358
  [f"Unsupported format. Source: {src_ext}, Dest: {dst_ext}"]
359
  )
360
-
361
  if src_ext != dst_ext:
362
  return _build_failed_pair_result(
363
  job_id, src_url, dst_url, started_at, "FILE_TYPE_MISMATCH",
364
  [f"File type mismatch. Source: {src_ext}, Dest: {dst_ext}"]
365
  )
366
-
 
 
367
  async with aiohttp.ClientSession() as session:
368
  try:
369
  src_data, dst_data = await asyncio.gather(
@@ -375,10 +399,11 @@ async def reconcile_pair(pair: Dict[str, str], job_id: str, numeric_keywords: Op
375
  return _build_failed_pair_result(
376
  job_id, src_url, dst_url, started_at, "FAILED", [str(e)]
377
  )
378
-
379
  if len(src_data) > _MAX_FILE_SIZE or len(dst_data) > _MAX_FILE_SIZE:
380
  return _build_failed_pair_result(
381
- job_id, src_url, dst_url, started_at, "FAILED", ["File size exceeds maximum allowed limit"]
 
382
  )
383
-
384
- return await process_pair(src_url, dst_url, src_data, dst_data, src_ext, job_id, numeric_keywords)
 
60
  return df
61
 
62
 
63
+ def apply_column_mapping(df: pd.DataFrame, mapping: Dict[str, str]) -> pd.DataFrame:
64
+ valid_mapping = {old: new for old, new in mapping.items() if old in df.columns}
65
+ if valid_mapping:
66
+ df = df.rename(columns=valid_mapping)
67
+ return df
68
+
69
+
70
  async def download_file_with_retry(session: aiohttp.ClientSession, url: str, correlation_id: str) -> bytes:
71
  for attempt in range(DOWNLOAD_MAX_RETRIES):
72
  try:
 
75
  return await response.read()
76
  except aiohttp.ClientError as e:
77
  wait_time = DOWNLOAD_BACKOFF_FACTOR * (2 ** attempt)
78
+ _logger.warning(
79
+ f"Download attempt {attempt+1} failed for {url}. Retrying in {wait_time}s. Error: {e}",
80
+ extra={"correlation_id": correlation_id}
81
+ )
82
  await asyncio.sleep(wait_time)
83
  raise DownloadError(f"Failed to download {url} after {DOWNLOAD_MAX_RETRIES} retries.")
84
 
 
262
  }
263
 
264
 
265
+ async def process_pair(
266
+ src_url: str,
267
+ dst_url: str,
268
+ src_data: bytes,
269
+ dst_data: bytes,
270
+ ext: str,
271
+ job_id: str,
272
+ column_mapping: Optional[Dict[str, str]] = None,
273
+ numeric_keywords: Optional[Set[str]] = None,
274
+ ) -> Dict[str, Any]:
275
  start_time = time.time()
276
  started_at = datetime.utcnow().isoformat()
277
  errors = []
278
  warnings = []
279
  status = "MATCH"
280
+
281
  try:
282
  df_src = read_to_dataframe(src_data, ext, job_id)
283
  df_dst = read_to_dataframe(dst_data, ext, job_id)
284
+
285
  if df_src.empty or df_dst.empty:
286
  raise EmptyDatasetError("One or both datasets are empty.")
287
+
288
  df_src = normalize_dataframe(df_src.copy())
289
  df_dst = normalize_dataframe(df_dst.copy())
290
  df_src.infer_objects()
291
  df_dst.infer_objects()
292
+
293
+ if column_mapping:
294
+ df_dst = apply_column_mapping(df_dst, column_mapping)
295
+
296
  schema_report = compare_schemas(df_src, df_dst)
297
  if not schema_report["fully_match"]:
298
  status = "SCHEMA_MISMATCH"
299
  warnings.append("Schema mismatch detected.")
300
+
301
  common_cols = df_src.columns.intersection(df_dst.columns).tolist()
302
+
303
  identical_rows, _, missing_rows, extra_rows = compare_rows(df_src, df_dst, common_cols)
304
+
305
  if missing_rows > 0 or extra_rows > 0:
306
  status = "PARTIAL_MATCH" if status == "MATCH" else status
307
+
308
  col_reports = reconcile_columns(df_src, df_dst, common_cols)
309
  num_reports = reconcile_numeric_columns(df_src, df_dst, common_cols, numeric_keywords)
310
  date_reports = reconcile_date_columns(df_src, df_dst, common_cols)
311
+
312
  dup_report = analyze_duplicates(df_src, df_dst)
313
  missing_report = analyze_missing_data(df_src, df_dst, common_cols)
314
+
315
  full_cols = sum(1 for c in col_reports if c["status"] == "fully_match")
316
  part_cols = sum(1 for c in col_reports if c["status"] == "partial_match")
317
+
318
  if part_cols > 0 and status == "MATCH":
319
  status = "PARTIAL_MATCH"
320
+
321
  total_max_rows = max(len(df_src), len(df_dst))
322
  match_pct = (identical_rows / total_max_rows * 100) if total_max_rows > 0 else 100.0
323
+
324
  summary = {
325
  "total_columns": len(common_cols),
326
  "fully_matched_columns": full_cols,
 
341
  errors.append(str(e))
342
  summary = {"overall_status": "FAILED", "overall_match_percentage": 0.0}
343
  schema_report, col_reports, num_reports, date_reports, dup_report, missing_report = {}, [], [], [], {}, []
344
+
 
 
 
345
  return {
346
  "job_id": job_id,
347
  "source_file": src_url,
348
  "destination_file": dst_url,
349
  "started_at": started_at,
350
+ "completed_at": datetime.utcnow().isoformat(),
351
+ "processing_time_ms": round((time.time() - start_time) * 1000, 2),
352
  "status": status,
353
  "summary": summary,
354
  "schema": schema_report,
 
362
  }
363
 
364
 
365
+ async def reconcile_pair(
366
+ pair: Dict[str, Any],
367
+ job_id: str,
368
+ numeric_keywords: Optional[Set[str]] = None,
369
+ ) -> Dict[str, Any]:
370
  src_url = pair["source"]
371
  dst_url = pair["destination"]
 
372
  started_at = datetime.utcnow().isoformat()
373
+
374
  src_ext = extract_extension(src_url)
375
  dst_ext = extract_extension(dst_url)
376
+
377
  if src_ext not in SUPPORTED_EXTENSIONS or dst_ext not in SUPPORTED_EXTENSIONS:
378
  return _build_failed_pair_result(
379
  job_id, src_url, dst_url, started_at, "FAILED",
380
  [f"Unsupported format. Source: {src_ext}, Dest: {dst_ext}"]
381
  )
382
+
383
  if src_ext != dst_ext:
384
  return _build_failed_pair_result(
385
  job_id, src_url, dst_url, started_at, "FILE_TYPE_MISMATCH",
386
  [f"File type mismatch. Source: {src_ext}, Dest: {dst_ext}"]
387
  )
388
+
389
+ column_mapping: Optional[Dict[str, str]] = pair.get("column_mapping")
390
+
391
  async with aiohttp.ClientSession() as session:
392
  try:
393
  src_data, dst_data = await asyncio.gather(
 
399
  return _build_failed_pair_result(
400
  job_id, src_url, dst_url, started_at, "FAILED", [str(e)]
401
  )
402
+
403
  if len(src_data) > _MAX_FILE_SIZE or len(dst_data) > _MAX_FILE_SIZE:
404
  return _build_failed_pair_result(
405
+ job_id, src_url, dst_url, started_at, "FAILED",
406
+ ["File size exceeds maximum allowed limit"]
407
  )
408
+
409
+ return await process_pair(src_url, dst_url, src_data, dst_data, src_ext, job_id, column_mapping, numeric_keywords)