Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions api/routes/meters.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,48 @@ def download_sold_meters_pdf(
)


@authenticated_meter_router.get(
"/meters/stored-report",
dependencies=[Depends(ScopedUser.Read)],
tags=["Meters"],
)
def get_stored_meters_report(
min_size: int | None = Query(None, ge=0),
max_size: int | None = Query(None, ge=0),
db: Session = Depends(get_db),
):
return meter_service.get_stored_meters_report(
db,
min_size,
max_size,
)


@authenticated_meter_router.get(
"/meters/stored-report/pdf",
dependencies=[Depends(ScopedUser.Read)],
tags=["Meters"],
)
def download_stored_meters_pdf(
min_size: int | None = Query(None, ge=0),
max_size: int | None = Query(None, ge=0),
db: Session = Depends(get_db),
):
pdf_io = meter_service.build_stored_meters_pdf(
db,
min_size,
max_size,
)

return StreamingResponse(
pdf_io,
media_type="application/pdf",
headers={
"Content-Disposition": "attachment; filename=stored_meters_report.pdf"
},
)


@authenticated_meter_router.get(
"/meters/installed-report",
dependencies=[Depends(ScopedUser.Read)],
Expand Down Expand Up @@ -323,6 +365,9 @@ def get_meters_locations(
location_only_activity_type_id = db.scalars(
select(ActivityTypeLU.id).where(ActivityTypeLU.name == "Location Only")
).first()
repair_activity_type_id = db.scalars(
select(ActivityTypeLU.id).where(ActivityTypeLU.name == "Repair")
).first()

if not pm_activity_type_id:
raise HTTPException(
Expand All @@ -334,6 +379,11 @@ def get_meters_locations(
status_code=500,
detail="Location Only activity type is not configured.",
)
if not repair_activity_type_id:
raise HTTPException(
status_code=500,
detail="Repair activity type is not configured.",
)

# Query latest PMs tied directly to the meter
meter_pm_query = text(
Expand All @@ -351,6 +401,23 @@ def get_meters_locations(
).fetchall()
meter_pm_dict = {row.meter_id: row.last_pm_meter_activity for row in meter_pm_rows}

repair_query = text(
"""
SELECT MAX(timestamp_start) AS last_repair_meter_activity, meter_id
FROM "MeterActivities"
WHERE activity_type_id = :repair_activity_type_id
AND meter_id = ANY(:mids)
GROUP BY meter_id
"""
)
repair_rows = db.execute(
repair_query,
{"mids": meter_ids, "repair_activity_type_id": repair_activity_type_id},
).fetchall()
repair_dict = {
row.meter_id: row.last_repair_meter_activity for row in repair_rows
}

location_only_dict = {}

if meter_ids:
Expand Down Expand Up @@ -394,6 +461,7 @@ def get_meters_locations(
"trss": row.trss,
},
last_pm_meter_activity=meter_pm_dict.get(row.id),
last_repair_meter_activity=repair_dict.get(row.id),
last_location_only_meter_activity=location_only_dict.get(row.id),
)
)
Expand Down
1 change: 1 addition & 0 deletions api/schemas/meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class LocationDTO(ORMBase):
well: WellDTO | None = None
location: LocationDTO | None = None
last_pm_meter_activity: datetime | None = None
last_repair_meter_activity: datetime | None = None
last_location_only_meter_activity: datetime | None = None


Expand Down
113 changes: 110 additions & 3 deletions api/services/meters.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
MeterActivities,
MeterObservations,
Meters,
MeterStatusLU,
MeterTypeLU,
)
from api.models.location import Locations
Expand Down Expand Up @@ -123,14 +124,16 @@ def get_meter_history(db: Session, meter_id: int):


def _meter_type_label(meter_type: MeterTypeLU) -> str:
size_label = f'{meter_type.size:g}"' if meter_type.size is not None else None

return " ".join(
filter(
None,
[
meter_type.brand,
meter_type.series,
meter_type.model,
f'{meter_type.size:g}"',
size_label,
],
)
)
Expand Down Expand Up @@ -236,7 +239,86 @@ def get_sold_meters_report(

type_totals = sorted(
type_totals_by_id.values(),
key=lambda row: (row["size"], row["meter_type"]),
key=lambda row: (row["size"] is None, row["size"] or 0, row["meter_type"]),
)

return {
"rows": rows,
"summary": {
"quantity": len(rows),
"total_value": total_value,
},
"type_totals": type_totals,
}


def get_stored_meters_report(
db: Session,
min_size: int | None = None,
max_size: int | None = None,
):
stmt = (
select(Meters, MeterTypeLU, MeterStatusLU)
.join(MeterTypeLU, MeterTypeLU.id == Meters.meter_type_id)
.join(MeterStatusLU, MeterStatusLU.id == Meters.status_id)
.where(MeterStatusLU.status_name.in_(["Warehouse", "On Hold"]))
.order_by(
MeterTypeLU.size.asc(),
MeterTypeLU.brand.asc(),
Meters.serial_number.asc(),
)
)

if min_size is not None:
stmt = stmt.where(MeterTypeLU.size >= min_size)
if max_size is not None:
stmt = stmt.where(MeterTypeLU.size <= max_size)

rows = []
type_totals_by_id = {}
total_value = 0.0

for meter, meter_type, status in db.execute(stmt).all():
price = float(meter.price or 0)
total_value += price
meter_type_label = _meter_type_label(meter_type)

rows.append(
{
"id": meter.id,
"serial_number": meter.serial_number,
"meter_owner": meter.meter_owner,
"contact_name": meter.contact_name,
"status": status.status_name,
"price": price,
"meter_type_id": meter_type.id,
"meter_type": meter_type_label,
"brand": meter_type.brand,
"series": meter_type.series,
"model": meter_type.model,
"size": meter_type.size,
"description": meter_type.description,
}
)

if meter_type.id not in type_totals_by_id:
type_totals_by_id[meter_type.id] = {
"id": meter_type.id,
"meter_type": meter_type_label,
"brand": meter_type.brand,
"series": meter_type.series,
"model": meter_type.model,
"size": meter_type.size,
"description": meter_type.description,
"quantity": 0,
"total_value": 0.0,
}
type_totals_by_id[meter_type.id]["quantity"] += 1
type_totals_by_id[meter_type.id]["total_value"] += price

type_totals = sorted(
type_totals_by_id.values(),
key=lambda row: (row["size"] is None, row["size"] or 0, row["meter_type"]),
)

return {
Expand Down Expand Up @@ -327,7 +409,7 @@ def get_installed_meters_report(

type_totals = sorted(
type_totals_by_id.values(),
key=lambda row: (row["size"], row["meter_type"]),
key=lambda row: (row["size"] is None, row["size"] or 0, row["meter_type"]),
)

return {
Expand Down Expand Up @@ -369,6 +451,31 @@ def build_sold_meters_pdf(
return pdf_io


def build_stored_meters_pdf(
db: Session,
min_size: int | None = None,
max_size: int | None = None,
):
report = get_stored_meters_report(db, min_size, max_size)
meter_type_chart = _make_meter_type_bar_chart(
report["type_totals"],
"Meters Stored",
)

html_content = templates.get_template("stored_meters_report.html").render(
rows=report["rows"],
summary=report["summary"],
type_totals=report["type_totals"],
meter_type_chart=meter_type_chart,
min_size=min_size,
max_size=max_size,
)
pdf_io = BytesIO()
HTML(string=html_content).write_pdf(pdf_io)
pdf_io.seek(0)
return pdf_io


def build_installed_meters_pdf(
db: Session,
from_date: date,
Expand Down
4 changes: 2 additions & 2 deletions api/templates/installed_meters_report.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ <h2>Meter Type Totals</h2>
{% for row in type_totals %}
<tr>
<td>{{ row.meter_type }}</td>
<td class="number">{{ "%g"|format(row.size) }}</td>
<td class="number">{{ "%g"|format(row.size) if row.size is not none else "" }}</td>
<td class="number">{{ row.quantity }}</td>
<td class="number">${{ "%.2f"|format(row.total_value) }}</td>
</tr>
Expand Down Expand Up @@ -84,7 +84,7 @@ <h2>Installed Meters</h2>
<td>{{ row.installed_date.strftime("%Y-%m-%d") }}</td>
<td>{{ row.serial_number }}</td>
<td>{{ row.meter_type }}</td>
<td class="number">{{ "%g"|format(row.size) }}</td>
<td class="number">{{ "%g"|format(row.size) if row.size is not none else "" }}</td>
<td>{{ row.well_ra_number or "" }}</td>
<td>{{ row.trss or "" }}</td>
<td>{{ row.water_users or "" }}</td>
Expand Down
4 changes: 2 additions & 2 deletions api/templates/sold_meters_report.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ <h2>Meter Type Totals</h2>
{% for row in type_totals %}
<tr>
<td>{{ row.meter_type }}</td>
<td class="number">{{ "%g"|format(row.size) }}</td>
<td class="number">{{ "%g"|format(row.size) if row.size is not none else "" }}</td>
<td class="number">{{ row.quantity }}</td>
<td class="number">${{ "%.2f"|format(row.total_value) }}</td>
</tr>
Expand All @@ -83,7 +83,7 @@ <h2>Sold Meters</h2>
<td>{{ row.sold_date.strftime("%Y-%m-%d") }}</td>
<td>{{ row.serial_number }}</td>
<td>{{ row.meter_type }}</td>
<td class="number">{{ "%g"|format(row.size) }}</td>
<td class="number">{{ "%g"|format(row.size) if row.size is not none else "" }}</td>
<td>{{ row.contact_name or "" }}</td>
<td>{{ row.meter_owner or "" }}</td>
<td class="number">${{ "%.2f"|format(row.price) }}</td>
Expand Down
95 changes: 95 additions & 0 deletions api/templates/stored_meters_report.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Stored Meters Report</title>
<style>
body { font-family: Arial, sans-serif; font-size: 12px; color: #1f2937; }
h1, h2 { margin-bottom: 6px; }
.meta { margin-bottom: 18px; color: #4b5563; }
.summary { display: table; margin-bottom: 18px; }
.summary div { display: table-row; }
.summary strong, .summary span { display: table-cell; padding: 2px 18px 2px 0; }
table { width: 100%; border-collapse: collapse; margin-bottom: 18px; }
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; }
th { background: #f3f4f6; }
td.number, th.number { text-align: right; }
tr { page-break-inside: avoid; }
.chart { margin: 12px 0 18px; text-align: center; }
.chart img { max-width: 100%; }
</style>
</head>
<body>
<h1>Stored Meters Report</h1>
<div class="meta">
Current stored meter inventory
{% if min_size is not none or max_size is not none %}
| Meter size:
{{ min_size if min_size is not none else "Any" }}
to
{{ max_size if max_size is not none else "Any" }}
{% endif %}
</div>

<div class="summary">
<div><strong>Total meters stored</strong><span>{{ summary.quantity }}</span></div>
<div><strong>Total value</strong><span>${{ "%.2f"|format(summary.total_value) }}</span></div>
</div>

<h2>Meter Type Totals</h2>
{% if meter_type_chart %}
<div class="chart">
<img src="data:image/png;base64,{{ meter_type_chart }}" />
</div>
{% endif %}

<table>
<thead>
<tr>
<th>Meter Type</th>
<th class="number">Size</th>
<th class="number">Quantity Stored</th>
<th class="number">Total Value</th>
</tr>
</thead>
<tbody>
{% for row in type_totals %}
<tr>
<td>{{ row.meter_type }}</td>
<td class="number">{{ "%g"|format(row.size) if row.size is not none else "" }}</td>
<td class="number">{{ row.quantity }}</td>
<td class="number">${{ "%.2f"|format(row.total_value) }}</td>
</tr>
{% endfor %}
</tbody>
</table>

<h2>Stored Meters</h2>
<table>
<thead>
<tr>
<th>Serial Number</th>
<th>Meter Type</th>
<th class="number">Size</th>
<th>Status</th>
<th>Contact</th>
<th>Owner</th>
<th class="number">Value</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.serial_number }}</td>
<td>{{ row.meter_type }}</td>
<td class="number">{{ "%g"|format(row.size) if row.size is not none else "" }}</td>
<td>{{ row.status }}</td>
<td>{{ row.contact_name or "" }}</td>
<td>{{ row.meter_owner or "" }}</td>
<td class="number">${{ "%.2f"|format(row.price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
Loading
Loading