@@ -575,8 +575,47 @@ def __init__(self, medicines=None, transient_batch_failures=0):
575575 self .medicines = medicines or []
576576 self .update_calls = []
577577 self .upsert_calls = []
578+ self .rpc_calls = []
578579 self .transient_batch_failures = transient_batch_failures
579580 self .transient_batch_attempts = 0
581+ # When set, the RPC returns this as response.data instead of the real
582+ # changed-row count — used to exercise short-count / unrecognized shapes.
583+ self .rpc_data_override = None
584+ self .rpc_override_set = False
585+
586+ def rpc (self , name , params ):
587+ """Fake the bulk_update_jan_aushadhi_price RPC: atomic UPDATE by id."""
588+ client = self
589+
590+ class _FakeRpc :
591+ def execute (self_inner ):
592+ client .rpc_calls .append ((name , params ))
593+ updates = params .get ("p_updates" ) or []
594+
595+ # Mirror the real loader's batch retry surface: a multi-row batch
596+ # can hit a transient error before succeeding on a later attempt.
597+ if len (updates ) > 1 :
598+ client .transient_batch_attempts += 1
599+ if client .transient_batch_attempts <= client .transient_batch_failures :
600+ raise TimeoutError (
601+ "connection timed out during Jan Aushadhi price bulk RPC"
602+ )
603+
604+ changed = 0
605+ for update in updates :
606+ row_id = update .get ("id" )
607+ new_price = update .get ("jan_aushadhi_price" )
608+ if row_id is None or new_price is None :
609+ continue
610+ for med in client .medicines :
611+ if med .get ("id" ) == row_id :
612+ med ["jan_aushadhi_price" ] = new_price
613+ changed += 1
614+ if client .rpc_override_set :
615+ return FakeExecuteResponse (client .rpc_data_override )
616+ return FakeExecuteResponse (changed )
617+
618+ return _FakeRpc ()
580619
581620 def table (self , name ):
582621 t = MergeFakeTable (name , self )
@@ -644,7 +683,78 @@ def test_ja_backfill_updates_null_jan_aushadhi_price_rows(tmp_path):
644683 assert medicines [1 ]["jan_aushadhi_price" ] == 25.00
645684
646685
647- def test_ja_backfill_retries_transient_batch_upsert_before_fallback (tmp_path , monkeypatch ):
686+ def test_ja_backfill_uses_bulk_rpc_with_id_and_price_only (tmp_path ):
687+ """Regression for #1966: back-fill goes through the bulk_update RPC with a
688+ {id, jan_aushadhi_price}-only payload, never a PostgREST upsert (which would
689+ fail the medicines.generic_name NOT NULL constraint and fall back to slow
690+ row-by-row PATCHes)."""
691+ medicines = [
692+ {"id" : "m1" , "generic_name" : "Paracetamol" , "strength" : "500mg" ,
693+ "source" : "commercial" , "jan_aushadhi_price" : None },
694+ ]
695+ nppa_csv = _write_nppa_csv (tmp_path , [
696+ {"generic_name" : "paracetamol" , "strength" : "500mg" , "mrp" : "18.50" },
697+ ])
698+ client = MergeFakeSupabaseClient (medicines = medicines )
699+ loader = make_merge_loader (client , tmp_path )
700+
701+ stats = loader .merge_jan_aushadhi_price (nppa_csv = nppa_csv )
702+
703+ assert stats ["updated" ] == 1
704+ assert stats ["failed" ] == 0
705+ # No upsert and no row-by-row fallback were used.
706+ assert client .upsert_calls == []
707+ assert client .update_calls == []
708+ # Exactly one bulk RPC call, carrying only id + jan_aushadhi_price.
709+ assert len (client .rpc_calls ) == 1
710+ name , params = client .rpc_calls [0 ]
711+ assert name == "bulk_update_jan_aushadhi_price"
712+ assert params ["p_updates" ] == [{"id" : "m1" , "jan_aushadhi_price" : 18.50 }]
713+
714+
715+ def test_ja_backfill_counts_short_rpc_result_as_failed (tmp_path ):
716+ """If the bulk RPC updates fewer rows than the batch (a row vanished between
717+ the page scan and the UPDATE), the shortfall is counted as failed so the
718+ checked == updated + skipped + failed invariant holds — not silently dropped."""
719+ medicines = [
720+ {"id" : "m1" , "generic_name" : "Paracetamol" , "jan_aushadhi_price" : None },
721+ ]
722+ client = MergeFakeSupabaseClient (medicines = medicines )
723+ loader = make_merge_loader (client , tmp_path )
724+
725+ # m2 has no matching medicine row, so the RPC reports 1 updated, not 2.
726+ batch = [
727+ {"id" : "m1" , "jan_aushadhi_price" : 18.50 },
728+ {"id" : "m2" , "jan_aushadhi_price" : 25.00 },
729+ ]
730+ updated , failed = loader ._upsert_ja_price_update_batches (batch , "medicines" )
731+
732+ assert updated == 1
733+ assert failed == 1
734+ assert medicines [0 ]["jan_aushadhi_price" ] == 18.50
735+ assert client .update_calls == [] # no row-by-row fallback was triggered
736+
737+
738+ def test_ja_backfill_assumes_full_batch_on_unrecognized_rpc_shape (tmp_path ):
739+ """An unrecognized RPC count shape is assumed to be a full success (the RPC
740+ committed without raising) rather than miscounted as failed."""
741+ medicines = [
742+ {"id" : "m1" , "generic_name" : "Paracetamol" , "jan_aushadhi_price" : None },
743+ ]
744+ client = MergeFakeSupabaseClient (medicines = medicines )
745+ client .rpc_override_set = True
746+ client .rpc_data_override = {"unexpected" : "shape" }
747+ loader = make_merge_loader (client , tmp_path )
748+
749+ batch = [{"id" : "m1" , "jan_aushadhi_price" : 18.50 }]
750+ updated , failed = loader ._upsert_ja_price_update_batches (batch , "medicines" )
751+
752+ assert updated == 1
753+ assert failed == 0
754+ assert client .update_calls == []
755+
756+
757+ def test_ja_backfill_retries_transient_batch_rpc_before_fallback (tmp_path , monkeypatch ):
648758 medicines = [
649759 {"id" : "m1" , "generic_name" : "Paracetamol" , "strength" : "500mg" ,
650760 "source" : "commercial" , "jan_aushadhi_price" : None },
@@ -671,7 +781,7 @@ def test_ja_backfill_retries_transient_batch_upsert_before_fallback(tmp_path, mo
671781 assert stats ["updated" ] == 2
672782 assert stats ["failed" ] == 0
673783 assert client .transient_batch_attempts == 3
674- assert len (client .upsert_calls ) == 3
784+ assert len (client .rpc_calls ) == 3
675785 assert client .update_calls == []
676786 assert len (sleep_calls ) == 2
677787
0 commit comments