async def materialize_campaign(db: AsyncSession, campaign: Campaign, recipient_rows: Sequence[Recipient], preview: bool = False, preview_size: int = 0) -> dict: """Create CampaignRecipient rows with generated values & assignments. This is the core deterministic plan: which subject/template/phone/account each recipient gets, plus generated invoice numbers (unique per campaign), amounts, shipping. """ subjects, templates, phones, accounts = await get_campaign_entities(db, campaign) var_cfg = dict(campaign.variable_config or {}) engine = VariableEngine(var_cfg) subj_sel = SubjectSelector(subjects, campaign.subject_rotation) tpl_seq = TemplateSequencer(templates, campaign.template_sequence_mode) phone_sel = PhoneSelector(phones, campaign.phone_selection) rot = RotationAssigner(accounts, campaign.rotation_strategy, campaign.rotation_weights, campaign.max_per_account) used_invoice_numbers: set = set() rows: List[CampaignRecipient] = [] plan = [] seq = 0 for r in recipient_rows: seq += 1 cr = CampaignRecipient( campaign_id=campaign.id, recipient_id=r.id, email=r.email, seq=seq, ) # generated values g = GeneratedValues() if var_cfg.get("invoice_date"): g.invoice_date = var_cfg["invoice_date"] if var_cfg.get("due_date"): g.due_date = var_cfg["due_date"] inv_num = "" tpl = tpl_seq.next_template() if tpl is None and templates: cr.status = "skipped" cr.last_error = "Template sequence exhausted (mode=stop)" body_uses_invoice = any("%RandomInvoiceNumber%" in (t.body_html + (t.body_text or "")) for t in templates) or (tpl and "%RandomInvoiceNumber%" in tpl.body_html) if body_uses_invoice or var_cfg.get("always_generate_invoice", True): inv_num = engine.generate_invoice_number(used_invoice_numbers) used_invoice_numbers.add(inv_num) cr.generated_invoice_number = inv_num g.invoice_number = inv_num if var_cfg.get("amount_enabled", True) or body_uses_invoice: g.amount = engine.generate_amount() cr.generated_amount = g.amount if var_cfg.get("shipping_enabled", True) or "%RandomShippingCharge%" in (tpl.body_html if tpl else ""): g.shipping = engine.generate_shipping() cr.generated_shipping = g.shipping subj = subj_sel.next_subject() cr.generated_subject_id = subj.id if subj else None cr.generated_template_id = tpl.id if tpl else None phone = phone_sel.next_phone() cr.generated_phone = phone g.phone = phone account = rot.next_account() if account is None: cr.status = "skipped" cr.last_error = "No available sender account (limits or cooldown)" else: cr.assigned_account_id = account.id rows.append(cr) plan.append({"seq": seq, "email": r.email, "invoice": inv_num, "amount": g.amount, "subject": subj.subject if subj else "", "template": tpl.name if tpl else None, "phone": phone, "account": account.email if account else None, "account_id": account.id if account else None, "recipient_id": r.id, "subject_id": subj.id if subj else None, "template_id": tpl.id if tpl else None, "invoice_number": inv_num, "invoice_date": g.invoice_date, "due_date": g.due_date, "shipping": g.shipping, "warnings": cr.last_error if cr.status == "skipped" else "", "status": cr.status}) if preview and seq >= (preview_size or campaign.preview_sample_size or 5): break if not preview: db.add_all(rows) await db.flush() return {"rows": rows, "plan": plan, "rotation": rot.status(), "account_count": len(accounts), "subjects": len(subjects), "templates": len(templates), "phones": len(phones)}