@@ -208,6 +208,146 @@ def delete_bill_permanent(bill_id):
208208 flash ('Bill and all its associated items have been permanently deleted.' , 'success' )
209209 return redirect (url_for ('finance.expenses' ))
210210
211+ @finance_bp .route ('/reconcile/atomic' , methods = ['POST' ])
212+ def atomic_reconcile ():
213+ user = _require_user ()
214+ if not user or user .role not in ['admin' , 'pantryHead' ]:
215+ return jsonify ({'error' : 'Unauthorized' }), 403
216+
217+ data = request .json
218+ if not data :
219+ return jsonify ({'error' : 'No data provided' }), 400
220+
221+ bill_id = data .get ('bill_id' )
222+ reconciliations = data .get ('reconciliations' , []) # List of {procurement_id: X, cost: Y}
223+
224+ if not bill_id :
225+ return jsonify ({'error' : 'Bill ID is required' }), 400
226+
227+ bill = tenant_filter (Bill .query ).filter_by (id = bill_id ).first ()
228+ if not bill :
229+ return jsonify ({'error' : 'Bill not found' }), 404
230+
231+ try :
232+ total_reconciled_cost = 0
233+ for rec in reconciliations :
234+ proc_id = rec .get ('procurement_id' )
235+ cost = float (rec .get ('cost' ) or 0 )
236+
237+ item = tenant_filter (ProcurementItem .query ).filter_by (id = proc_id ).first ()
238+ if item and (user .role == 'admin' or item .floor == bill .floor ):
239+ item .actual_cost = cost
240+ item .bill_id = bill .id
241+ item .status = 'completed'
242+ item .expense_recorded_at = datetime .utcnow ()
243+ total_reconciled_cost += cost
244+
245+ # Update bill total if it was a manual reconciliation adding to an existing bill
246+ # Or if we want the bill to reflect the sum of its items
247+ current_total = tenant_filter (db .session .query (func .sum (ProcurementItem .actual_cost ))).filter (ProcurementItem .bill_id == bill .id ).scalar () or 0
248+ bill .total_amount = current_total
249+
250+ db .session .commit ()
251+ return jsonify ({'success' : True , 'reconciled_count' : len (reconciliations ), 'new_total' : float (bill .total_amount )})
252+ except Exception as e :
253+ db .session .rollback ()
254+ return jsonify ({'error' : str (e )}), 500
255+
256+ @finance_bp .route ('/procurement/unbilled' , methods = ['GET' ])
257+ def get_unbilled_procurement ():
258+ user = _require_user ()
259+ if not user :
260+ return jsonify ({'error' : 'Unauthorized' }), 401
261+
262+ floor = _get_active_floor (user )
263+ # Get items that are EITHER completed and unbilled, OR still pending
264+ # This allows matching a bill to something that was just bought but not yet marked done
265+ items = tenant_filter (ProcurementItem .query ).filter (
266+ ProcurementItem .floor == floor ,
267+ ProcurementItem .bill_id == None
268+ ).order_by (ProcurementItem .status .desc (), ProcurementItem .created_at .desc ()).all ()
269+
270+ return jsonify ({
271+ 'items' : [{
272+ 'id' : i .id ,
273+ 'name' : i .item_name ,
274+ 'quantity' : i .quantity ,
275+ 'status' : i .status ,
276+ 'category' : i .category ,
277+ 'created_at' : i .created_at .strftime ('%Y-%m-%d' )
278+ } for i in items ]
279+ })
280+
281+ @finance_bp .route ('/reconcile/atomic/full' , methods = ['POST' ])
282+ def atomic_reconcile_full ():
283+ user = _require_user ()
284+ if not user or user .role not in ['admin' , 'pantryHead' ]:
285+ return jsonify ({'error' : 'Unauthorized' }), 403
286+
287+ data = request .json
288+ if not data :
289+ return jsonify ({'error' : 'No data provided' }), 400
290+
291+ floor = _get_active_floor (user )
292+ try :
293+ bill_date_str = data .get ('bill_date' )
294+ bill_date = datetime .strptime (bill_date_str , '%Y-%m-%d' ).date () if bill_date_str else date .today ()
295+
296+ # 1. Create the Bill
297+ bill = Bill (
298+ bill_no = data .get ('bill_no' ) or f"REC-{ int (datetime .utcnow ().timestamp ())} " ,
299+ bill_date = bill_date ,
300+ shop_name = data .get ('shop_name' ) or 'Generic Vendor' ,
301+ total_amount = data .get ('total_amount' , 0 ),
302+ floor = floor ,
303+ source = 'receipt_scan' ,
304+ original_filename = data .get ('filename' ),
305+ tenant_id = getattr (g , 'tenant_id' , None )
306+ )
307+ db .session .add (bill )
308+ db .session .flush ()
309+
310+ # 2. Create NEW ProcurementItems
311+ for item_data in data .get ('new_items' , []):
312+ item = ProcurementItem (
313+ item_name = item_data .get ('name' ),
314+ quantity = item_data .get ('quantity' ),
315+ category = 'other' ,
316+ priority = 'medium' ,
317+ status = 'completed' ,
318+ floor = floor ,
319+ created_by_id = user .id ,
320+ actual_cost = item_data .get ('cost' ),
321+ expense_recorded_at = datetime .utcnow (),
322+ bill_id = bill .id ,
323+ tenant_id = getattr (g , 'tenant_id' , None )
324+ )
325+ db .session .add (item )
326+
327+ # 3. Reconcile EXISTING ProcurementItems
328+ for rec in data .get ('reconciliations' , []):
329+ proc_id = rec .get ('procurement_id' )
330+ cost = float (rec .get ('cost' ) or 0 )
331+
332+ item = tenant_filter (ProcurementItem .query ).filter_by (id = proc_id ).first ()
333+ if item and (user .role == 'admin' or item .floor == floor ):
334+ item .actual_cost = cost
335+ item .bill_id = bill .id
336+ item .status = 'completed'
337+ item .expense_recorded_at = datetime .utcnow ()
338+
339+ # 4. Final Total Sync
340+ db .session .flush () # Ensure all items have costs applied
341+ final_total = tenant_filter (db .session .query (func .sum (ProcurementItem .actual_cost ))).filter (ProcurementItem .bill_id == bill .id ).scalar () or 0
342+ bill .total_amount = final_total
343+
344+ db .session .commit ()
345+ return jsonify ({'success' : True , 'bill_id' : bill .id })
346+ except Exception as e :
347+ db .session .rollback ()
348+ logging .error (f"Atomic Full Reconcile Error: { str (e )} " )
349+ return jsonify ({'error' : str (e )}), 500
350+
211351@finance_bp .route ('/budgets/add' , methods = ['POST' ])
212352def add_budget ():
213353 user = _require_user ()
0 commit comments