diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index acfce9e..6c8008f 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -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= (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= (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}) diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index c90dc3a..97e14b4 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -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 [ {