mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 11:53:13 +00:00
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:
parent
f175323939
commit
5785f39590
1 changed files with 77 additions and 17 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue