diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..11f6b58 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,12 @@ +name: CI +on: + pull_request: + branches: [main] + +jobs: + ci: + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main + with: + source-dirs: "lambdas cdk" + run-cdk-synth: true + run-sam-validate: false diff --git a/cdk/app.py b/cdk/app.py index 1c5f42f..2fac78d 100644 --- a/cdk/app.py +++ b/cdk/app.py @@ -3,7 +3,9 @@ import aws_cdk as cdk from stack import PoIngestStack app = cdk.App() -PoIngestStack(app, "po-ingest", +PoIngestStack( + app, + "po-ingest", stack_name="po-ingest", env=cdk.Environment(region="us-east-1"), ) diff --git a/cdk/stack.py b/cdk/stack.py index 92cd7c0..6f4bc86 100644 --- a/cdk/stack.py +++ b/cdk/stack.py @@ -14,7 +14,6 @@ from aws_cdk import ( aws_ses as ses, aws_ses_actions as ses_actions, aws_secretsmanager as secretsmanager, - aws_iam as iam, ) from constructs import Construct @@ -25,7 +24,8 @@ class PoIngestStack(Stack): # --- S3 bucket for raw emails --- email_bucket = s3.Bucket( - self, "EmailBucket", + self, + "EmailBucket", bucket_name=f"po-ingest-emails-{self.account}", removal_policy=RemovalPolicy.RETAIN, lifecycle_rules=[ @@ -37,7 +37,8 @@ class PoIngestStack(Stack): # Owned by this stack. Streams enabled for the site-extractor pipeline. # Other stacks (seahaven-slack-bot) reference this table via fromTableName(). po_table = dynamodb.Table( - self, "PurchaseOrdersTable", + self, + "PurchaseOrdersTable", table_name="purchase-orders", partition_key=dynamodb.Attribute( name="po_number", @@ -50,7 +51,8 @@ class PoIngestStack(Stack): # --- Secrets Manager for Anthropic API key --- anthropic_secret = secretsmanager.Secret( - self, "AnthropicApiKey", + self, + "AnthropicApiKey", secret_name="po-ingest/anthropic-api-key", description="Anthropic API key for Coupa PO email parsing", removal_policy=RemovalPolicy.RETAIN, @@ -58,12 +60,23 @@ class PoIngestStack(Stack): # --- Lambda function --- email_processor = lambda_.Function( - self, "EmailProcessor", + self, + "EmailProcessor", function_name="po-email-processor", runtime=lambda_.Runtime.PYTHON_3_12, architecture=lambda_.Architecture.ARM_64, handler="handler.handler", - code=lambda_.Code.from_asset("../lambdas/email_processor/package"), + code=lambda_.Code.from_asset( + "../lambdas/email_processor", + bundling=cdk.BundlingOptions( + image=lambda_.Runtime.PYTHON_3_12.bundling_image, + command=[ + "bash", + "-c", + "pip install -r requirements.txt -t /asset-output && cp handler.py /asset-output/", + ], + ), + ), timeout=Duration.seconds(60), memory_size=256, log_retention=logs.RetentionDays.TWO_MONTHS, @@ -88,7 +101,9 @@ class PoIngestStack(Stack): # --- SES Receipt Rule --- # Reuse the existing INBOUND_MAIL rule set (shared with workorder-ingest) rule_set = ses.ReceiptRuleSet.from_receipt_rule_set_name( - self, "ExistingRuleSet", "INBOUND_MAIL", + self, + "ExistingRuleSet", + "INBOUND_MAIL", ) rule_set.add_rule( @@ -104,7 +119,8 @@ class PoIngestStack(Stack): # --- Web UI Lambda --- web_ui = lambda_.Function( - self, "WebUI", + self, + "WebUI", function_name="po-web-ui", runtime=lambda_.Runtime.PYTHON_3_12, architecture=lambda_.Architecture.ARM_64, @@ -125,11 +141,14 @@ class PoIngestStack(Stack): auth_type=lambda_.FunctionUrlAuthType.NONE, ) - cdk.CfnOutput(self, "WebUIUrl", value=web_url.url, description="PO Dashboard URL") + cdk.CfnOutput( + self, "WebUIUrl", value=web_url.url, description="PO Dashboard URL" + ) # --- Verified sites table (extracted from PO ship-to addresses) --- verified_sites_table = dynamodb.Table( - self, "VerifiedSitesTable", + self, + "VerifiedSitesTable", table_name="verified-sites", partition_key=dynamodb.Attribute( name="siteCode", @@ -149,7 +168,8 @@ class PoIngestStack(Stack): # --- Site extractor Lambda (DynamoDB Streams → verified-sites) --- site_extractor = lambda_.Function( - self, "SiteExtractor", + self, + "SiteExtractor", function_name="po-ingest-site-extractor", runtime=lambda_.Runtime.PYTHON_3_12, architecture=lambda_.Architecture.ARM_64, @@ -177,14 +197,17 @@ class PoIngestStack(Stack): ) ) - cdk.CfnOutput(self, "VerifiedSitesTableName", + cdk.CfnOutput( + self, + "VerifiedSitesTableName", value=verified_sites_table.table_name, description="Verified site addresses extracted from POs", ) # --- Pending site review table (POs with no extractable site code) --- pending_review_table = dynamodb.Table( - self, "PendingSiteReviewTable", + self, + "PendingSiteReviewTable", table_name="pending-site-review", partition_key=dynamodb.Attribute( name="po_number", diff --git a/lambdas/email_processor/handler.py b/lambdas/email_processor/handler.py index 51daa88..9c5b620 100644 --- a/lambdas/email_processor/handler.py +++ b/lambdas/email_processor/handler.py @@ -290,7 +290,7 @@ def pad_zip(zip_code: str | None) -> str | None: return zip_code clean = zip_code.strip().split("-")[0] if clean.isdigit() and len(clean) < 5: - return clean.zfill(5) + zip_code.strip()[len(clean):] + return clean.zfill(5) + zip_code.strip()[len(clean) :] return zip_code @@ -369,7 +369,9 @@ def handler(event, context): logger.info(f"Subject: {email_data['subject']}") parsed = extract_with_claude(email_data) - logger.info(f"Parsed: type={parsed.get('email_type')}, po={parsed.get('po_number')}") + logger.info( + f"Parsed: type={parsed.get('email_type')}, po={parsed.get('po_number')}" + ) if not parsed.get("po_number"): logger.warning(f"No PO number found in email, skipping: {key}") diff --git a/lambdas/site_extractor/handler.py b/lambdas/site_extractor/handler.py index f3a9eff..a90bc93 100644 --- a/lambdas/site_extractor/handler.py +++ b/lambdas/site_extractor/handler.py @@ -49,9 +49,7 @@ def extract_site_code(record: dict) -> str | None: if m and SITE_CODE_PATTERN.match(m.group(1)): return m.group(1) - m = re.search( - r"(?:Station|DS)\s*[-–]?\s*([A-Z0-9]{3,5})\b", ship_to_name - ) + m = re.search(r"(?:Station|DS)\s*[-–]?\s*([A-Z0-9]{3,5})\b", ship_to_name) if m and SITE_CODE_PATTERN.match(m.group(1)): return m.group(1) @@ -85,7 +83,11 @@ def parse_address(record: dict) -> dict: result["city"] = ship_to.get("city") result["state"] = ship_to["state"] result["zip"] = ship_to.get("zip") - parts = [p for p in [result["address"], result["city"], result["state"], result["zip"]] if p] + parts = [ + p + for p in [result["address"], result["city"], result["state"], result["zip"]] + if p + ] result["fullAddress"] = ", ".join(parts) return result @@ -104,11 +106,13 @@ def parse_address(record: dict) -> dict: result["city"] = m.group(1).strip() result["state"] = m.group(2) result["zip"] = m.group(3) - result["fullAddress"] = f"{result['address']}, {result['city']}, {result['state']} {result['zip']}" + result["fullAddress"] = ( + f"{result['address']}, {result['city']}, {result['state']} {result['zip']}" + ) return result # Fallback: try newline-separated format - lines = [l.strip() for l in clean.split("\n") if l.strip()] + lines = [line.strip() for line in clean.split("\n") if line.strip()] city_re = re.compile(r"^(.+?),\s+([A-Z]{2})\s+(\d{5}(?:-\d{4})?)") for i, line in enumerate(lines): m = city_re.match(line) @@ -118,7 +122,9 @@ def parse_address(record: dict) -> dict: result["state"] = m.group(2) result["zip"] = m.group(3) street = result["address"] or result["city"] - result["fullAddress"] = f"{street}, {result['city']}, {result['state']} {result['zip']}" + result["fullAddress"] = ( + f"{street}, {result['city']}, {result['state']} {result['zip']}" + ) return result result["address"] = clean @@ -278,7 +284,10 @@ def handler(event, context): ship_to_name = (po.get("ship_to") or {}).get("name") write_pending_review(po_number, address, ship_to_name) pending += 1 - logger.info("PO %s added to pending review (no site code or address match)", po_number) + logger.info( + "PO %s added to pending review (no site code or address match)", + po_number, + ) logger.info( "Batch complete: %d processed, %d extracted (%d via address), %d pending review", diff --git a/lambdas/web_ui/handler.py b/lambdas/web_ui/handler.py index fecf9e7..90ef3c6 100644 --- a/lambdas/web_ui/handler.py +++ b/lambdas/web_ui/handler.py @@ -5,7 +5,6 @@ Serves a simple HTML dashboard for viewing purchase orders. Accessed via Lambda Function URL. """ -import json import os from decimal import Decimal @@ -103,7 +102,12 @@ def render_po_detail(po): view_url = po.get("view_order_url") if view_url: - fields.append(("Coupa Link", f'View in Coupa')) + fields.append( + ( + "Coupa Link", + f'View in Coupa', + ) + ) details_html = "" for label, value in fields: @@ -120,17 +124,17 @@ def render_po_detail(po): if line_items: rows = "" for item in line_items: - qty = item.get('quantity', '') or '' - unit = item.get('unit', '') or '' - price = item.get('price', '') or '' + qty = item.get("quantity", "") or "" + unit = item.get("unit", "") or "" + price = item.get("price", "") or "" rows += f""" - {item.get('description', '')} + {item.get("description", "")} {qty} {unit} {price} - {fmt_currency(item.get('amount'))} - {item.get('need_by', '') or ''} + {fmt_currency(item.get("amount"))} + {item.get("need_by", "") or ""} """ items_html = f"""