mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 09:33:15 +00:00
* Add CI workflow and apply ruff formatting * Disable cdk synth — email_processor uses pre-built package dir The email_processor Lambda bundles deps into a gitignored package/ directory. cdk synth fails in CI without a build step to recreate it. Disabling until packaging is standardized. * Use CDK BundlingOptions for email_processor Lambda packaging Replaces the pre-built gitignored package/ directory with CDK's built-in bundling. Deps are now installed inside a Docker container during cdk synth, so the build works identically locally and in CI. Re-enables run-cdk-synth in the CI workflow.
273 lines
11 KiB
Python
273 lines
11 KiB
Python
"""
|
|
Web UI Lambda.
|
|
|
|
Serves a simple HTML dashboard for viewing purchase orders.
|
|
Accessed via Lambda Function URL.
|
|
"""
|
|
|
|
import os
|
|
from decimal import Decimal
|
|
|
|
import boto3
|
|
|
|
dynamodb = boto3.resource("dynamodb")
|
|
|
|
PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders")
|
|
|
|
|
|
def get_purchase_orders(limit=500):
|
|
table = dynamodb.Table(PO_TABLE)
|
|
items = []
|
|
response = table.scan()
|
|
items.extend(response.get("Items", []))
|
|
while "LastEvaluatedKey" in response:
|
|
response = table.scan(ExclusiveStartKey=response["LastEvaluatedKey"])
|
|
items.extend(response.get("Items", []))
|
|
items.sort(key=lambda x: x.get("processed_at", ""), reverse=True)
|
|
return items[:limit]
|
|
|
|
|
|
def render_badge(value, color_map):
|
|
if not value:
|
|
value = "unknown"
|
|
color = color_map.get(value.lower(), "#9ca3af")
|
|
label = value.replace("_", " ").title()
|
|
return f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500;">{label}</span>'
|
|
|
|
|
|
STATUS_COLORS = {
|
|
"issued": "#3b82f6",
|
|
"pending buyer action": "#f59e0b",
|
|
"open": "#3b82f6",
|
|
"closed": "#10b981",
|
|
"cancelled": "#ef4444",
|
|
"soft closed": "#6b7280",
|
|
}
|
|
|
|
EMAIL_TYPE_COLORS = {
|
|
"new_po": "#3b82f6",
|
|
"revision": "#f59e0b",
|
|
"cancellation": "#ef4444",
|
|
}
|
|
|
|
|
|
def fmt_currency(val):
|
|
if val is None:
|
|
return ""
|
|
if isinstance(val, Decimal):
|
|
val = float(val)
|
|
if isinstance(val, (int, float)):
|
|
return f"${val:,.2f}"
|
|
return str(val)
|
|
|
|
|
|
def render_po_detail(po):
|
|
po_number = po.get("po_number", "")
|
|
|
|
fields = [
|
|
("PO Number", po_number),
|
|
("Status", render_badge(po.get("po_status", ""), STATUS_COLORS)),
|
|
("Email Type", render_badge(po.get("email_type", ""), EMAIL_TYPE_COLORS)),
|
|
("Total Amount", fmt_currency(po.get("total_amount"))),
|
|
("Currency", po.get("currency")),
|
|
("Supplier", (po.get("supplier") or {}).get("name")),
|
|
("Site Code", po.get("site_code")),
|
|
("State", po.get("state")),
|
|
("Trade", po.get("trade")),
|
|
("Fiscal Year", po.get("fiscal_year")),
|
|
("Coupa Category", po.get("coupa_category")),
|
|
("Submitted By", po.get("submitted_by")),
|
|
("On Behalf Of", po.get("on_behalf_of")),
|
|
("Order Date", po.get("order_date")),
|
|
("Revision Date", po.get("revision_date")),
|
|
("Payment Terms", po.get("payment_terms")),
|
|
("Requisition #", po.get("requisition_number")),
|
|
("Department", po.get("department")),
|
|
("Data Source", po.get("data_source")),
|
|
("Processed At", po.get("processed_at")),
|
|
]
|
|
|
|
ship_to = po.get("ship_to") or {}
|
|
if any(ship_to.values()):
|
|
ship_parts = []
|
|
if ship_to.get("name"):
|
|
ship_parts.append(ship_to["name"])
|
|
if ship_to.get("address"):
|
|
ship_parts.append(ship_to["address"])
|
|
if ship_to.get("location_code"):
|
|
ship_parts.append(f"Location: {ship_to['location_code']}")
|
|
if ship_to.get("attn"):
|
|
ship_parts.append(f"Attn: {ship_to['attn']}")
|
|
fields.append(("Ship To", "<br>".join(ship_parts)))
|
|
|
|
view_url = po.get("view_order_url")
|
|
if view_url:
|
|
fields.append(
|
|
(
|
|
"Coupa Link",
|
|
f'<a href="{view_url}" target="_blank" style="color:#3b82f6;">View in Coupa</a>',
|
|
)
|
|
)
|
|
|
|
details_html = ""
|
|
for label, value in fields:
|
|
if value:
|
|
details_html += f"""
|
|
<div style="display:flex;padding:8px 0;border-bottom:1px solid #f1f5f9;">
|
|
<div style="width:140px;font-size:13px;color:#64748b;font-weight:500;">{label}</div>
|
|
<div style="flex:1;font-size:14px;color:#1e293b;">{value}</div>
|
|
</div>"""
|
|
|
|
# Line items
|
|
line_items = po.get("line_items") or []
|
|
items_html = ""
|
|
if line_items:
|
|
rows = ""
|
|
for item in line_items:
|
|
qty = item.get("quantity", "") or ""
|
|
unit = item.get("unit", "") or ""
|
|
price = item.get("price", "") or ""
|
|
rows += f"""
|
|
<tr style="border-bottom:1px solid #f1f5f9;">
|
|
<td style="padding:10px;font-size:14px;">{item.get("description", "")}</td>
|
|
<td style="padding:10px;font-size:13px;text-align:right;">{qty}</td>
|
|
<td style="padding:10px;font-size:13px;">{unit}</td>
|
|
<td style="padding:10px;font-size:13px;text-align:right;">{price}</td>
|
|
<td style="padding:10px;font-size:14px;text-align:right;">{fmt_currency(item.get("amount"))}</td>
|
|
<td style="padding:10px;font-size:13px;color:#64748b;">{item.get("need_by", "") or ""}</td>
|
|
</tr>"""
|
|
|
|
items_html = f"""
|
|
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
|
<h2 style="font-size:16px;margin-bottom:16px;">Line Items ({len(line_items)})</h2>
|
|
<table style="width:100%;border-collapse:collapse;">
|
|
<thead>
|
|
<tr>
|
|
<th style="text-align:left;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Description</th>
|
|
<th style="text-align:right;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Qty</th>
|
|
<th style="text-align:left;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Unit</th>
|
|
<th style="text-align:right;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Price</th>
|
|
<th style="text-align:right;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Amount</th>
|
|
<th style="text-align:left;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Need By</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{rows}</tbody>
|
|
</table>
|
|
</div>"""
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>PO {po_number} - Sea Haven</title>
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="max-width:800px;margin:0 auto;padding:20px;">
|
|
<div style="margin-bottom:20px;">
|
|
<a href="/" style="color:#3b82f6;text-decoration:none;font-size:14px;">← All Purchase Orders</a>
|
|
</div>
|
|
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);margin-bottom:20px;">
|
|
<h1 style="font-size:20px;margin-bottom:16px;">Purchase Order {po_number}</h1>
|
|
{details_html}
|
|
</div>
|
|
{items_html}
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def render_po_list(purchase_orders):
|
|
rows = ""
|
|
for po in purchase_orders:
|
|
po_number = po.get("po_number", "")
|
|
supplier = (po.get("supplier") or {}).get("name", "")
|
|
site_code = po.get("site_code", "")
|
|
trade = po.get("trade", "")
|
|
status = po.get("po_status", "")
|
|
total = fmt_currency(po.get("total_amount"))
|
|
processed = (po.get("processed_at") or "")[:16]
|
|
|
|
rows += f"""
|
|
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location='/po?id={po_number}'">
|
|
<td style="padding:12px;font-weight:500;color:#3b82f6;">{po_number}</td>
|
|
<td style="padding:12px;">{supplier}</td>
|
|
<td style="padding:12px;font-weight:500;">{site_code}</td>
|
|
<td style="padding:12px;font-size:13px;">{trade}</td>
|
|
<td style="padding:12px;">{render_badge(status, STATUS_COLORS)}</td>
|
|
<td style="padding:12px;text-align:right;">{total}</td>
|
|
<td style="padding:12px;color:#64748b;font-size:13px;">{processed}</td>
|
|
</tr>"""
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Purchase Orders - Sea Haven</title>
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
|
|
table {{ width: 100%; border-collapse: collapse; }}
|
|
tr:hover {{ background: #f8fafc; }}
|
|
th {{ text-align: left; padding: 12px; font-size: 12px; text-transform: uppercase; color: #64748b; border-bottom: 2px solid #e2e8f0; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="max-width:1100px;margin:0 auto;padding:20px;">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
|
<h1 style="font-size:22px;">Purchase Orders</h1>
|
|
<span style="color:#64748b;font-size:14px;">{len(purchase_orders)} most recent</span>
|
|
</div>
|
|
<div style="background:#fff;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,0.08);overflow:hidden;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>PO #</th>
|
|
<th>Supplier</th>
|
|
<th>Site</th>
|
|
<th>Trade</th>
|
|
<th>Status</th>
|
|
<th style="text-align:right;">Amount</th>
|
|
<th>Processed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows if rows else '<tr><td colspan="7" style="padding:40px;text-align:center;color:#94a3b8;">No purchase orders yet.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def handler(event, context):
|
|
path = event.get("rawPath", "/")
|
|
qs = event.get("queryStringParameters") or {}
|
|
|
|
if path == "/po" and "id" in qs:
|
|
po_number = qs["id"]
|
|
table = dynamodb.Table(PO_TABLE)
|
|
result = table.get_item(Key={"po_number": po_number})
|
|
po = result.get("Item")
|
|
if not po:
|
|
return {
|
|
"statusCode": 404,
|
|
"headers": {"Content-Type": "text/html"},
|
|
"body": "<h1>Purchase order not found</h1>",
|
|
}
|
|
html = render_po_detail(po)
|
|
else:
|
|
purchase_orders = get_purchase_orders()
|
|
html = render_po_list(purchase_orders)
|
|
|
|
return {
|
|
"statusCode": 200,
|
|
"headers": {"Content-Type": "text/html"},
|
|
"body": html,
|
|
}
|