printedparts stage 9: reports - stock w/ reconcile, consumption, by-person
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s

Three jwt-optional endpoints with ?format=csv, merged into the reports
hub via get_reports while the plugin is enabled. The stock report's
ledgerdelta column is the reconcile check: 0 for every item whose
stock moved through the ledger, nonzero for anything that bypassed it
(the hand-seeded dev rows demonstrate the catch). MySQL SUM returns
Decimal - cast to int or the delta serializes as a string.
This commit is contained in:
cproudlock
2026-07-17 08:04:09 -04:00
parent 6439d1ccd9
commit b68e927ef6
2 changed files with 136 additions and 0 deletions

View File

@@ -346,3 +346,113 @@ def kiosk_take():
_ledger_write(item, 'take', -quantity, sso, name)
return success_response(item.to_dict(),
message=f'Took {quantity}, {item.quantityonhand} left')
# --- reports (merged into GET /api/reports while the plugin is enabled) ------
import csv
import io
from flask import Response
from sqlalchemy import func
def _csv_response(rows, columns, filename):
"""CSV download; local helper because generate_csv is not on the
contract surface (shopdb.api)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
for row in rows:
writer.writerow([row.get(column, '') for column in columns])
return Response(
output.getvalue(), mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename={filename}'})
@printedparts_bp.route('/reports/stock', methods=['GET'])
@jwt_required(optional=True)
def report_stock():
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
ledgerdelta should always be 0; anything else means a write path
bypassed the single-commit rule and needs finding.
"""
# int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a
# string and breaks the delta arithmetic's type.
ledger = {itemid: int(total) for itemid, total in
db.session.query(
PrintedItemTransaction.printeditemid,
func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0))
.group_by(PrintedItemTransaction.printeditemid).all()}
rows = []
for item in PrintedItem.query.filter_by(isactive=True).order_by(
PrintedItem.itemname).all():
rows.append({
'itemcode': item.itemcode,
'itemname': item.itemname,
'binlocation': item.binlocation or '',
'quantityonhand': item.quantityonhand,
'lowstockthreshold': item.lowstockthreshold,
'islowstock': item.islowstock,
'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0),
})
columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand',
'lowstockthreshold', 'islowstock', 'ledgerdelta']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-stock.csv')
return success_response({'columns': columns, 'rows': rows})
@printedparts_bp.route('/reports/consumption', methods=['GET'])
@jwt_required(optional=True)
def report_consumption():
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItem.itemcode,
PrintedItem.itemname,
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.join(PrintedItemTransaction,
PrintedItemTransaction.printeditemid == PrintedItem.printeditemid)
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname)
rows = [{'itemcode': code, 'itemname': name, 'takes': takes,
'quantitytaken': int(taken)}
for code, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['itemcode', 'itemname', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-consumption.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})
@printedparts_bp.route('/reports/by-person', methods=['GET'])
@jwt_required(optional=True)
def report_by_person():
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItemTransaction.employeesso,
func.max(PrintedItemTransaction.employeename),
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItemTransaction.employeesso)
rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes,
'quantitytaken': int(taken)}
for sso, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['employeesso', 'employeename', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-by-person.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})

View File

@@ -62,6 +62,32 @@ class PrintedpartsPlugin(BasePlugin):
'printedparts'),
]
def get_reports(self) -> List[dict]:
return [
{
'id': 'printedparts-stock',
'name': '3D Parts Stock',
'description': 'Stock levels with low-stock flags and the '
'cache-vs-ledger reconcile check',
'category': 'inventory',
'endpoint': '/api/printedparts/reports/stock',
},
{
'id': 'printedparts-consumption',
'name': '3D Parts Consumption',
'description': 'Takes per item over a date range',
'category': 'usage',
'endpoint': '/api/printedparts/reports/consumption',
},
{
'id': 'printedparts-by-person',
'name': '3D Parts by Person',
'description': 'Takes grouped by employee',
'category': 'usage',
'endpoint': '/api/printedparts/reports/by-person',
},
]
def get_navigation_items(self) -> List[dict]:
return [
{