use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use backbone_inventory::application::service::inventory_gl::{
AccountingPostEnvelope, GlPostAck, GlPostRejected, GlPostSink,
};
use backbone_inventory::application::service::inventory_move_engine::MoveGlDirective;
use backbone_inventory::application::service::inventory_transfer::PickingLine;
use backbone_inventory::application::service::inventory_write_service::{
InventoryError, InventoryWriteService, NewWarehouse,
};
struct CountingSink { posts: AtomicUsize }
#[async_trait::async_trait]
impl GlPostSink for CountingSink {
async fn post(&self, _e: &AccountingPostEnvelope) -> Result<GlPostAck, GlPostRejected> {
self.posts.fetch_add(1, Ordering::SeqCst);
Ok(GlPostAck { post_id: Uuid::new_v4(), journal_id: Uuid::new_v4(), idempotent_reuse: false })
}
}
fn counting_sink() -> Arc<CountingSink> { Arc::new(CountingSink { posts: AtomicUsize::new(0) }) }
fn d(s: &str) -> Decimal { Decimal::from_str_exact(s).unwrap() }
fn uq(p: &str) -> String { format!("{p}-{}", &Uuid::new_v4().simple().to_string()[..8]) }
async fn pool() -> PgPool {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5433/backbone_inventory".to_string());
PgPool::connect(&url).await.expect("connect DB")
}
fn gl() -> MoveGlDirective {
MoveGlDirective {
cogs_account_id: Some(Uuid::new_v4()),
inventory_account_id: Some(Uuid::new_v4()),
grir_account_id: Some(Uuid::new_v4()),
adjustment_account_id: Some(Uuid::new_v4()),
currency: "IDR".into(),
}
}
async fn warehouse(w: &InventoryWriteService) -> Uuid {
w.create_warehouse(NewWarehouse {
code: uq("WH"), name: uq("Main"),
warehouse_type: None, parent_warehouse_id: None, is_group: false,
}).await.unwrap()
}
async fn loc(pool: &PgPool, usage: &str, wh: Option<Uuid>) -> Uuid {
let id = Uuid::new_v4();
let name = uq("LOC");
sqlx::query(
r#"INSERT INTO inventory.locations
(id, name, complete_name, usage, parent_path, warehouse_id)
VALUES ($1,$2,$3,$4::location_usage,$5,$6)"#,
)
.bind(id).bind(&name).bind(&name).bind(usage).bind("").bind(wh)
.execute(pool).await.unwrap();
id
}
async fn op_type(pool: &PgPool, code: &str, reservation: &str, src: Uuid, dst: Uuid) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
r#"INSERT INTO inventory.operation_types
(id, name, sequence_code, code,
default_location_src_id, default_location_dest_id, reservation_method, create_backorder)
VALUES ($1,$2,$3,$4::picking_code,$5,$6,$7::reservation_method,'ask'::create_backorder)"#,
)
.bind(id).bind(uq("PT")).bind(uq("SEQ")).bind(code)
.bind(src).bind(dst).bind(reservation)
.execute(pool).await.unwrap();
id
}
async fn seed_bin(pool: &PgPool, item: Uuid, wh: Uuid, qty: &str, rate: &str) {
sqlx::query(
r#"INSERT INTO inventory.bins
(id, item_id, warehouse_id, actual_qty, reserved_qty, valuation_rate, stock_value)
VALUES ($1,$2,$3,$4,0,$5,$6)"#,
)
.bind(Uuid::new_v4()).bind(item).bind(wh)
.bind(d(qty)).bind(d(rate)).bind(d(qty) * d(rate))
.execute(pool).await.unwrap();
}
async fn seed_quant(pool: &PgPool, item: Uuid, location: Uuid, qty: &str) {
sqlx::query(
r#"INSERT INTO inventory.stock_quants
(id, item_id, location_id, quantity, reserved_quantity, available_quantity)
VALUES ($1,$2,$3,$4,0,$4)"#,
)
.bind(Uuid::new_v4()).bind(item).bind(location).bind(d(qty))
.execute(pool).await.unwrap();
}
async fn member_picking(
svc: &InventoryWriteService,
pool: &PgPool,
wh: Uuid,
reservation: &str,
) -> Uuid {
let supplier = loc(pool, "supplier", None).await;
let stock = loc(pool, "internal", Some(wh)).await;
let op = op_type(pool, "incoming", reservation, supplier, stock).await;
let item = Uuid::new_v4();
let created = svc.create_picking(backbone_inventory::application::service::inventory_transfer::NewPicking {
name: uq("BP"), picking_type_id: op,
location_id: supplier, location_dest_id: stock, partner_id: None,
move_type: "direct".into(), origin: None,
lines: vec![PickingLine { item_id: item, demand_qty: d("5"), price_unit: d("1") }],
}).await.unwrap();
created.transfer_id
}
#[tokio::test]
async fn batch_state_derives_from_members() {
let pool = pool().await;
let svc = InventoryWriteService::new(pool.clone());
let wh = warehouse(&svc).await;
let header = svc.create_batch(uq("BATCH"), false, None).await.unwrap();
assert_eq!(header.state, "draft");
assert!(!header.had_members);
let batch_id = header.id;
let a = member_picking(&svc, &pool, wh, "manual").await;
let b = member_picking(&svc, &pool, wh, "at_confirm").await;
for (picking, want) in [(a, "confirmed"), (b, "assigned")] {
let (h, _) = svc.fetch_picking(picking).await.unwrap();
assert_eq!(h.state, want, "member staging sanity for {picking}");
}
svc.add_picking_to_batch(batch_id, a).await.unwrap();
let (h, members) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "draft");
assert_eq!(members.len(), 1);
svc.add_picking_to_batch(batch_id, b).await.unwrap();
let (h, members) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "draft", "least-advanced live member wins over the assigned one");
assert_eq!(members.len(), 2);
assert!(h.had_members);
svc.remove_picking_from_batch(batch_id, a).await.unwrap();
let (h, members) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "ready");
assert_eq!(members.len(), 1);
svc.add_picking_to_batch(batch_id, a).await.unwrap();
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "draft");
}
#[tokio::test]
async fn engine_cascade_reprojects_the_batch() {
let pool = pool().await;
let svc = InventoryWriteService::new(pool.clone());
let wh = warehouse(&svc).await;
let batch_id = svc.create_batch(uq("BATCH"), true, None).await.unwrap().id;
let a = member_picking(&svc, &pool, wh, "manual").await;
let b = member_picking(&svc, &pool, wh, "manual").await;
svc.add_picking_to_batch(batch_id, a).await.unwrap();
svc.add_picking_to_batch(batch_id, b).await.unwrap();
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "draft");
let sink = counting_sink();
svc.validate_picking(a, &gl(), &*sink).await.unwrap();
let (h, _) = svc.fetch_picking(a).await.unwrap();
assert_eq!(h.state, "done");
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "draft", "one done + one confirmed member: least-advanced wins");
svc.validate_picking(b, &gl(), &*sink).await.unwrap();
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "done", "every live member done → the batch reads done");
}
#[tokio::test]
async fn all_members_cancelled_cancels_the_batch() {
let pool = pool().await;
let svc = InventoryWriteService::new(pool.clone());
let wh = warehouse(&svc).await;
let batch_id = svc.create_batch(uq("BATCH"), false, None).await.unwrap().id;
let a = member_picking(&svc, &pool, wh, "manual").await;
svc.add_picking_to_batch(batch_id, a).await.unwrap();
let (_, moves) = svc.fetch_picking(a).await.unwrap();
for m in moves {
svc.action_cancel(m.id).await.unwrap();
}
let (h, _) = svc.fetch_picking(a).await.unwrap();
assert_eq!(h.state, "cancel");
let (h, members) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "cancel");
assert_eq!(members.len(), 1, "the cancelled member still groups — it only drops out of the aggregation");
}
#[tokio::test]
async fn emptying_the_batch_auto_cancels() {
let pool = pool().await;
let svc = InventoryWriteService::new(pool.clone());
let wh = warehouse(&svc).await;
let batch_id = svc.create_batch(uq("BATCH"), false, None).await.unwrap().id;
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "draft");
let a = member_picking(&svc, &pool, wh, "manual").await;
svc.add_picking_to_batch(batch_id, a).await.unwrap();
svc.remove_picking_from_batch(batch_id, a).await.unwrap();
let (h, members) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "cancel");
assert_eq!(members.len(), 0);
}
#[tokio::test]
async fn waiting_member_holds_batch_at_waiting() {
let pool = pool().await;
let svc = InventoryWriteService::new(pool.clone());
let wh = warehouse(&svc).await;
let batch_id = svc.create_batch(uq("BATCH"), false, None).await.unwrap().id;
let a = member_picking(&svc, &pool, wh, "manual").await;
let b = member_picking(&svc, &pool, wh, "at_confirm").await;
sqlx::query("UPDATE inventory.transfers SET state = 'waiting'::transfer_state WHERE id = $1")
.bind(a).execute(&pool).await.unwrap();
svc.add_picking_to_batch(batch_id, a).await.unwrap();
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "waiting", "a single waiting member holds the batch at waiting");
svc.add_picking_to_batch(batch_id, b).await.unwrap();
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "waiting");
svc.remove_picking_from_batch(batch_id, a).await.unwrap();
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "ready");
svc.add_picking_to_batch(batch_id, a).await.unwrap();
let (h, _) = svc.fetch_batch(batch_id).await.unwrap();
assert_eq!(h.state, "waiting");
}
#[tokio::test]
async fn batch_name_unique() {
let pool = pool().await;
let svc = InventoryWriteService::new(pool.clone());
let name = uq("BATCH");
svc.create_batch(name.clone(), false, None).await.unwrap();
let second = svc.create_batch(name.clone(), false, None).await.unwrap();
assert_ne!(second.id, Uuid::nil(), "undecorated, the module admits the duplicate");
let uniques: i64 = sqlx::query_scalar(
"SELECT count(*) FROM pg_indexes WHERE schemaname = 'inventory' \
AND tablename = 'picking_batches' AND indexdef ILIKE 'CREATE UNIQUE%' \
AND indexname NOT LIKE '%\\_pkey'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
uniques, 0,
"the module ships no picking_batches unique — the decorator owns the (org unit, name) slot"
);
}
#[tokio::test]
async fn membership_guards() {
let pool = pool().await;
let svc = InventoryWriteService::new(pool.clone());
let wh = warehouse(&svc).await;
let batch_id = svc.create_batch(uq("BATCH"), false, None).await.unwrap().id;
let second_batch = svc.create_batch(uq("BATCH"), false, None).await.unwrap().id;
let done = member_picking(&svc, &pool, wh, "manual").await;
let sink = counting_sink();
svc.validate_picking(done, &gl(), &*sink).await.unwrap();
let err = svc.add_picking_to_batch(batch_id, done).await.unwrap_err();
assert!(matches!(err, InventoryError::PickingTerminalForBatch { .. }), "got {err:?}");
let cancelled = member_picking(&svc, &pool, wh, "manual").await;
let (_, moves) = svc.fetch_picking(cancelled).await.unwrap();
for m in moves {
svc.action_cancel(m.id).await.unwrap();
}
let err = svc.add_picking_to_batch(batch_id, cancelled).await.unwrap_err();
assert!(matches!(err, InventoryError::PickingTerminalForBatch { .. }), "got {err:?}");
let member = member_picking(&svc, &pool, wh, "manual").await;
svc.add_picking_to_batch(batch_id, member).await.unwrap();
let err = svc.add_picking_to_batch(second_batch, member).await.unwrap_err();
assert!(matches!(err, InventoryError::PickingAlreadyBatched { .. }), "got {err:?}");
let a = member_picking(&svc, &pool, wh, "manual").await;
let b = member_picking(&svc, &pool, wh, "manual").await;
svc.add_picking_to_batch(second_batch, a).await.unwrap();
svc.add_picking_to_batch(second_batch, b).await.unwrap();
svc.validate_picking(a, &gl(), &*sink).await.unwrap();
svc.validate_picking(b, &gl(), &*sink).await.unwrap();
let (h, _) = svc.fetch_batch(second_batch).await.unwrap();
assert_eq!(h.state, "done");
let c = member_picking(&svc, &pool, wh, "manual").await;
let err = svc.add_picking_to_batch(second_batch, c).await.unwrap_err();
assert!(matches!(err, InventoryError::BatchTerminal { .. }), "got {err:?}");
svc.remove_picking_from_batch(second_batch, a).await.unwrap();
let err = svc.remove_picking_from_batch(batch_id, c).await.unwrap_err();
assert!(matches!(err, InventoryError::NotABatchMember { .. }), "got {err:?}");
}