printedparts stage 9: reports - stock w/ reconcile, consumption, by-person
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:
@@ -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})
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user