|
|
|
|
@@ -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})
|
|
|
|
|
|