Merge PO revisions and handle out-of-order events

save_revision did a full put_item overwrite, so a revision omitting
line_items/supplier permanently deleted them. save_new_po used a
conditional put that silently dropped the PO when an out-of-order
cancellation had already created a skeleton row.

Switch both to field-level merge update_items: a revision now SETs
only the fields it carries, and a new_po backfills data into a
pre-existing Cancelled skeleton while preserving the Cancelled
status. No email can now delete data established by an earlier one.
This commit is contained in:
Adam Moussa 2026-06-17 11:37:35 -04:00
parent f175323939
commit 5785f39590

View file

@ -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(