backbone-inventory 0.17.2

Inventory: stock ledger of record (SLE) + moving-average valuation; emits COGS/asset AccountingPost (Delivery/Receipt -> GL)
Documentation
//! Repository for DeliveryNote entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the hand-written DeliveryNote SQL
//! (4-layer rule: services orchestrate, repositories hold the SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<DeliveryNote, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

use anyhow::Result;
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;

use backbone_orm::org_scope;

use super::gl_voucher_repository::GlSettlementState;

use crate::domain::entity::DeliveryNote;

/// Table name for DeliveryNote entities
pub const TABLE_NAME: &str = "inventory.delivery_notes";

/// Repository for DeliveryNote entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct DeliveryNoteRepository(
    backbone_orm::GenericCrudRepository<DeliveryNote, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for DeliveryNoteRepository {
    type Target = backbone_orm::GenericCrudRepository<DeliveryNote, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target { &self.0 }
}

impl DeliveryNoteRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// The exact row a draft Delivery Note header insert writes.
///
/// Mirrors the raw column shape rather than the `DeliveryNote` entity: `total_cogs` starts as a
/// literal 0 (it is only known once the movement consumes the moving average at submit), and
/// `status`/`posting_state` are literal casts.
pub struct NewDeliveryRow<'a> {
    pub id: Uuid,
    pub delivery_number: &'a str,
    pub branch_id: Option<Uuid>,
    pub customer_id: Uuid,
    pub source_so_id: Option<Uuid>,
    pub warehouse_id: Uuid,
    pub posting_date: chrono::NaiveDate,
    pub currency: &'a str,
    pub cogs_account_id: Uuid,
    pub inventory_account_id: Uuid,
}

/// The submit path's header projection — everything needed to drive the movement + the COGS post.
pub struct DeliverySubmitHeaderRow {
    pub delivery_number: String,
    pub branch_id: Option<Uuid>,
    pub warehouse_id: Uuid,
    pub posting_date: chrono::NaiveDate,
    pub source_so_id: Option<Uuid>,
    pub currency: String,
    /// `status::text` — the submit path refuses anything but "draft".
    pub status: String,
    pub cogs_account_id: Uuid,
    pub inventory_account_id: Uuid,
}

/// The repost path's header projection — rebuilds the SAME envelope from the stored header.
pub struct DeliveryRepostHeaderRow {
    pub branch_id: Option<Uuid>,
    pub delivery_number: String,
    pub posting_date: chrono::NaiveDate,
    pub currency: String,
    pub total_cogs: Decimal,
    pub cogs_account_id: Uuid,
    pub inventory_account_id: Uuid,
    /// The warehouse whose stock location resolves the location valuation-account override —
    /// the repost must rebuild the SAME envelope the submit built, override included.
    pub warehouse_id: Uuid,
    pub gl: GlSettlementState,
}

/// The cancel path's header projection — everything needed to reverse the outflow and emit the
/// `posting_type='reversal'` post. Carries the original `accounting_post_id` (to reverse) and any
/// already-recorded `reversal_accounting_post_id` (the idempotency/recovery short-circuit).
pub struct DeliveryCancelHeaderRow {
    pub branch_id: Option<Uuid>,
    pub warehouse_id: Uuid,
    pub posting_date: chrono::NaiveDate,
    pub currency: String,
    pub delivery_number: String,
    /// The original posted COGS — the reversal GL leg re-posts this amount (Dr Inventory · Cr COGS).
    pub total_cogs: Decimal,
    /// `status::text` — a fresh cancel requires "submitted"; "cancelled" is the recovery short-circuit.
    pub status: String,
    pub cogs_account_id: Uuid,
    pub inventory_account_id: Uuid,
    pub gl: GlSettlementState,
    pub reversal_journal_id: Option<Uuid>,
    pub reversal_accounting_post_id: Option<Uuid>,
}

/// Hand-written DeliveryNote SQL. Lives here per the module's 4-layer rule.
impl DeliveryNoteRepository {
    /// Insert the draft delivery header.
    ///
    /// Takes the CALLER'S connection so the header and its items commit as one unit. The caller has
    /// already relayed the ambient org scope onto it — don't re-bind here.
    ///
    /// Returns the raw `sqlx::Error` deliberately: the caller inspects it for a unique violation to
    /// turn a duplicate delivery number into `DuplicateNumber`.
    pub async fn insert_draft(
        &self,
        conn: &mut sqlx::PgConnection,
        d: &NewDeliveryRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO inventory.delivery_notes
                (id, delivery_number, branch_id, customer_id, source_so_id, warehouse_id,
                 posting_date, currency, total_cogs, cogs_account_id, inventory_account_id, status, posting_state)
               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,0,$9,$10,'draft'::doc_status,'pending'::gl_posting_state)"#,
        )
        .bind(d.id).bind(d.delivery_number).bind(d.branch_id).bind(d.customer_id)
        .bind(d.source_so_id).bind(d.warehouse_id).bind(d.posting_date).bind(d.currency)
        .bind(d.cogs_account_id).bind(d.inventory_account_id)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Read the submit path's header. `Ok(None)` = no such live delivery in scope.
    ///
    /// ID-only: the scoped read helper rides the ambient org scope's request-dedicated connection,
    /// so the composing decorator's fence (ADR-0029) bounds what the read can see.
    pub async fn fetch_submit_header(
        &self,
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<DeliverySubmitHeaderRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT delivery_number, branch_id, warehouse_id, posting_date, source_so_id,
                          currency, status::text AS st, cogs_account_id, inventory_account_id
                   FROM inventory.delivery_notes WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(id),
        )
        .await?;
        Ok(row.map(|h| DeliverySubmitHeaderRow {
            delivery_number: h.get("delivery_number"),
            branch_id: h.get("branch_id"), warehouse_id: h.get("warehouse_id"),
            posting_date: h.get("posting_date"), source_so_id: h.get("source_so_id"),
            currency: h.get("currency"),
            status: h.get("st"), cogs_account_id: h.get("cogs_account_id"),
            inventory_account_id: h.get("inventory_account_id"),
        }))
    }

    /// Read the repost path's header. Same ID-only scope fence as [`Self::fetch_submit_header`].
    pub async fn fetch_repost_header(
        &self,
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<DeliveryRepostHeaderRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT branch_id, delivery_number, posting_date, currency, total_cogs,
                          cogs_account_id, inventory_account_id, posting_state::text AS ps, journal_id, accounting_post_id,
                          warehouse_id
                   FROM inventory.delivery_notes WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(id),
        )
        .await?;
        Ok(row.map(|h| DeliveryRepostHeaderRow {
            branch_id: h.get("branch_id"),
            delivery_number: h.get("delivery_number"), posting_date: h.get("posting_date"),
            currency: h.get("currency"),
            total_cogs: h.get("total_cogs"), cogs_account_id: h.get("cogs_account_id"),
            inventory_account_id: h.get("inventory_account_id"),
            warehouse_id: h.get("warehouse_id"),
            gl: GlSettlementState {
                posting_state: h.get("ps"), journal_id: h.get("journal_id"),
                accounting_post_id: h.get("accounting_post_id"),
            },
        }))
    }

    /// Flip the delivery draft→submitted and record the COGS the movement actually consumed. Must
    /// ride the movement's transaction.
    pub async fn mark_submitted_with_cogs(
        &self,
        conn: &mut sqlx::PgConnection,
        id: Uuid,
        total_cogs: Decimal,
    ) -> Result<(), sqlx::Error> {
        sqlx::query("UPDATE inventory.delivery_notes SET status='submitted'::doc_status, total_cogs=$2 WHERE id=$1")
            .bind(id).bind(total_cogs)
            .execute(conn)
            .await?;
        Ok(())
    }

    /// Flip the delivery submitted→cancelled. Must ride the cancellation's transaction. Guarded on
    /// `submitted` so a draft or an already-cancelled delivery is not touched here.
    pub async fn mark_cancelled(
        &self,
        conn: &mut sqlx::PgConnection,
        id: Uuid,
    ) -> Result<(), sqlx::Error> {
        sqlx::query("UPDATE inventory.delivery_notes SET status='cancelled'::doc_status WHERE id=$1 AND status='submitted'::doc_status")
            .bind(id)
            .execute(conn)
            .await?;
        Ok(())
    }

    /// Read the cancel path's header. Same ID-only scope fence as [`Self::fetch_submit_header`].
    pub async fn fetch_cancel_header(
        &self,
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<DeliveryCancelHeaderRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT branch_id, warehouse_id, posting_date, currency, delivery_number,
                          total_cogs, status::text AS st, cogs_account_id, inventory_account_id,
                          posting_state::text AS ps, journal_id, accounting_post_id,
                          reversal_journal_id, reversal_accounting_post_id
                   FROM inventory.delivery_notes WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(id),
        )
        .await?;
        Ok(row.map(|h| DeliveryCancelHeaderRow {
            branch_id: h.get("branch_id"),
            warehouse_id: h.get("warehouse_id"), posting_date: h.get("posting_date"),
            currency: h.get("currency"), delivery_number: h.get("delivery_number"),
            total_cogs: h.get("total_cogs"),
            status: h.get("st"), cogs_account_id: h.get("cogs_account_id"),
            inventory_account_id: h.get("inventory_account_id"),
            reversal_journal_id: h.get("reversal_journal_id"),
            reversal_accounting_post_id: h.get("reversal_accounting_post_id"),
            gl: GlSettlementState {
                posting_state: h.get("ps"), journal_id: h.get("journal_id"),
                accounting_post_id: h.get("accounting_post_id"),
            },
        }))
    }
}

backbone_core::impl_crud_repository!(DeliveryNoteRepository, DeliveryNote, soft_delete);