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)
|
_ledger_write(item, 'take', -quantity, sso, name)
|
||||||
return success_response(item.to_dict(),
|
return success_response(item.to_dict(),
|
||||||
message=f'Took {quantity}, {item.quantityonhand} left')
|
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'),
|
'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]:
|
def get_navigation_items(self) -> List[dict]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user