diff --git a/lambdas/po/email_processor/handler.py b/lambdas/po/email_processor/handler.py index ceb1f00..ed1c527 100644 --- a/lambdas/po/email_processor/handler.py +++ b/lambdas/po/email_processor/handler.py @@ -387,32 +387,92 @@ def enrich_parsed(parsed: dict, s3_key: str, email_subject: str): return parsed -def save_new_po(parsed: dict): - """Insert a new PO into DynamoDB. Skips if po_number already exists.""" - table = dynamodb.Table(PO_TABLE) - item = {k: v for k, v in parsed.items() if v is not None} +def _merge_update(po_number: str, fields: dict): + """Apply a merge (SET-only) update of the given fields onto a PO record. - try: - table.put_item( - Item=item, - ConditionExpression="attribute_not_exists(po_number)", + Only the fields supplied are written; absent fields are left untouched, so a + partial payload can never delete data that an earlier email established. The + record is created if it does not exist (DynamoDB update_item upsert). + """ + table = dynamodb.Table(PO_TABLE) + + set_parts = [] + attr_names = {} + attr_values = {} + for key, value in fields.items(): + if value is None or key == "po_number": + continue + name_ph = f"#{key}" + val_ph = f":{key}" + attr_names[name_ph] = key + attr_values[val_ph] = value + set_parts.append(f"{name_ph} = {val_ph}") + + if not set_parts: + return + + table.update_item( + Key={"po_number": po_number}, + UpdateExpression="SET " + ", ".join(set_parts), + ExpressionAttributeNames=attr_names, + ExpressionAttributeValues=attr_values, + ) + + +def _is_cancelled(po_number: str) -> bool: + """Return True if the PO already exists with a Cancelled status.""" + table = dynamodb.Table(PO_TABLE) + existing = table.get_item(Key={"po_number": po_number}).get("Item") + return bool(existing) and existing.get("po_status") == "Cancelled" + + +def save_new_po(parsed: dict): + """Create a PO, merging into any pre-existing record. + + Uses a merge update rather than a conditional put so that an out-of-order + cancellation (which leaves a Cancelled skeleton) is filled in with the full + PO data instead of the new_po being silently dropped. The cancellation marker + (po_status=Cancelled, cancelled_at) is preserved; new_po data backfills the + remaining fields. + """ + po_number = parsed["po_number"] + fields = {k: v for k, v in parsed.items() if v is not None} + if _is_cancelled(po_number): + # Preserve the cancellation: don't overwrite po_status/cancelled_at. + fields.pop("po_status", None) + fields.pop("cancelled_at", None) + logger.info( + f"PO {po_number} was cancelled before new_po arrived; " + f"backfilling data and keeping Cancelled status" ) - logger.info(f"Created PO {parsed['po_number']}") - except dynamodb.meta.client.exceptions.ConditionalCheckFailedException: - logger.info(f"PO {parsed['po_number']} already exists, skipping insert") + + _merge_update(po_number, fields) + logger.info(f"Created/merged PO {po_number}") def save_revision(parsed: dict): - """Update an existing PO with revised data, or insert if it doesn't exist yet.""" - table = dynamodb.Table(PO_TABLE) - item = {k: v for k, v in parsed.items() if v is not None} + """Merge revised data into an existing PO without deleting omitted fields. - table.put_item(Item=item) - logger.info(f"Revised PO {parsed['po_number']}") + A revision email often omits unchanged sections (line_items, supplier). The + previous full-overwrite put_item permanently dropped those. This SETs only the + fields present in the revision, leaving everything else intact. + """ + po_number = parsed["po_number"] + fields = {k: v for k, v in parsed.items() if v is not None} + if _is_cancelled(po_number): + # A revision must not silently un-cancel a PO. + fields.pop("po_status", None) + _merge_update(po_number, fields) + logger.info(f"Revised PO {po_number}") def save_cancellation(parsed: dict): - """Update an existing PO's status to Cancelled.""" + """Mark a PO Cancelled, creating a minimal skeleton if it doesn't exist yet. + + If the cancellation arrives before the new_po, the skeleton it creates is + later backfilled by save_new_po (which preserves this Cancelled status), so no + PO data is lost on out-of-order delivery. + """ table = dynamodb.Table(PO_TABLE) table.update_item(