Fosi Fossil Service
Small teams. Open source. Built on Fossil.

IaC / SwiftFlow

High-performance WMS with wave picking, smart routing, and real-time inventory visibility.

Public | Owner: IaC | Default branch: trunk | Last update: 2026-10-23 14:37

Artifact f9ff9bb700bd5a576c0808ab2aff7f8abf08417c5992b2ac8f86d0bf56e09630:


import access
import batch_code
import config
import database
import gleeunit
import gleeunit/should
import gleam/list
import gleam/option.{None, Some}
import gleam/result
import gleam/string
import location
import location_plan
import pace
import password
import picking
import receiving
import repo.{type ReceiptLine, ReceiptLine}
import shipping
import warehouse_scope
import putaway
import rbac
import wave
import ql361api/models.{
  type PurchaseOrder, type SalesOrder, GoodsItem, PurchaseOrder, PurchaseOrderItem,
  PurchaseOrderSummary, QlWarehouse, SalesOrder, SalesOrderItem, SalesOrderSummary,
}
import schema
import simplifile
import stock_move
import sync_products
import user_repo

pub fn main() -> Nil {
  gleeunit.main()
}

pub fn postgres_placeholders_test() {
  database.postgres_placeholders("select * from t where a = ? and b = ?")
  |> should.equal("select * from t where a = $1 and b = $2")
  database.adapt("select 1", config.Sqlite)
  |> should.equal("select 1")
}

pub fn sqlite_roundtrip_uses_only_written_rows_test() {
  let path = "build/test-swiftflow.sqlite"
  let _ = simplifile.delete(path)
  let _ = simplifile.delete(path <> "-wal")
  let _ = simplifile.delete(path <> "-shm")
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)

  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      let warehouse = QlWarehouse(id: 1, code: "00002", fullname: "总仓", easycode: "ZC")
      let product =
        GoodsItem(
          id: 11,
          code: "c001",
          name: "测试商品",
          barcode: "6901234567890",
          unit: "盒",
          spec: "400g",
          shelf_life: "180天",
          shelf_life_days: 180,
          position_code: "",
          disabled: False,
          is_category: False,
          modified_at: "2026-10-01 08:00:00",
          created_at: "2026-01-01 00:00:00",
        )
      let summary =
        SalesOrderSummary(
          bill_id: 7,
          bill_code: "XSDD-7",
          customer_name: "客户",
          contact_person: "",
          phone: "",
          warehouse_name: "总仓",
          warehouse_id: 1,
          bill_date: "2026-10-01",
          status: 20,
          status_name: "待发货",
          total_qty: 10.0,
          total_money: 20.0,
          remark: "",
        )
      let detail =
        SalesOrder(
          bill_id: 7,
          bill_code: "XSDD-7",
          customer_name: "客户",
          contact_person: "",
          phone: "",
          warehouse_name: "总仓",
          warehouse_id: 1,
          bill_date: "2026-10-01",
          status: 20,
          status_name: "待发货",
          total_qty: 10.0,
          total_money: 20.0,
          remark: "",
          items: [
            SalesOrderItem(
              bill_detail_id: 9,
              sku_id: 11,
              goods_code: "c001",
              goods_name: "测试商品",
              barcode: "6901234567890",
              spec: "400g",
              package_qty: 1.0,
              package_unit: "件",
              base_qty: 10.0,
              base_unit: "盒",
              price: 2.0,
              total_money: 20.0,
              position_code: "",
              area_code: "",
            ),
          ],
        )
      let assert Ok(_) =
        repo.upsert_warehouse(conn, warehouse, True, "2026-10-23 10:00:00")
      let assert Ok(_) = repo.upsert_product(conn, product, "2026-10-23 10:00:00")
      let assert Ok(_) = repo.upsert_order_summary(conn, summary, "2026-10-23 10:00:00")
      let assert Ok(Some(stamp)) = repo.get_order_stamp(conn, 7)
      stamp.detail_synced_at |> should.equal("")
      repo.needs_detail(Some(stamp), summary) |> should.be_true
      let assert Ok(_) = repo.save_order_detail(conn, detail, "2026-10-23 10:01:00")
      let assert Ok(Some(saved)) = repo.get_order(conn, 7)
      saved.items |> list.length |> should.equal(1)
      let assert Ok(line) = list.first(saved.items)
      line.position_code |> should.equal("")
      let assert Ok(page) = repo.list_products(conn, "6901234567890", 10, 0)
      page.total |> should.equal(1)
      let assert Ok(pending) = repo.list_orders(conn, rbac.AllWarehouses, Some(20), 10, 0)
      pending.total |> should.equal(1)
      Ok(Nil)
    })
}

pub fn picking_keeps_source_location_test() {
  let order =
    SalesOrder(
      bill_id: 7,
      bill_code: "XSDD-7",
      customer_name: "客户",
      contact_person: "",
      phone: "",
      warehouse_name: "总仓",
      warehouse_id: 1,
      bill_date: "2026-10-01",
      status: 20,
      status_name: "待发货",
      total_qty: 10.0,
      total_money: 20.0,
      remark: "",
      items: [
        SalesOrderItem(
          bill_detail_id: 9,
          sku_id: 11,
          goods_code: "c001",
          goods_name: "测试商品",
          barcode: "690",
          spec: "",
          package_qty: 1.0,
          package_unit: "件",
          base_qty: 10.0,
          base_unit: "盒",
          price: 2.0,
          total_money: 20.0,
          position_code: "",
          area_code: "",
        ),
      ],
    )
  let task = picking.generate_single_order_picking(order)
  let assert Ok(line) = list.first(task.items)
  line.suggested_location |> should.equal("")
  let assert Ok(allocation) = list.first(line.allocations)
  allocation.slot_id |> should.equal("XSDD-7")
}

pub fn product_cursor_uses_latest_modified_time_test() {
  let items = [
    GoodsItem(
      id: 1,
      code: "a",
      name: "a",
      barcode: "",
      unit: "",
      spec: "",
      shelf_life: "",
      shelf_life_days: 0,
      position_code: "",
      disabled: False,
      is_category: False,
      modified_at: "2026-10-01 08:00:00",
      created_at: "",
    ),
    GoodsItem(
      id: 2,
      code: "b",
      name: "b",
      barcode: "",
      unit: "",
      spec: "",
      shelf_life: "",
      shelf_life_days: 0,
      position_code: "",
      disabled: False,
      is_category: True,
      modified_at: "2026-10-02 09:00:00",
      created_at: "",
    ),
  ]
  sync_products.max_modified("2026-09-01 00:00:00", items)
  |> should.equal("2026-10-02 09:00:00")
}

pub fn inbound_sqlite_roundtrip_test() {
  let path = "build/test-inbound.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      let summary =
        PurchaseOrderSummary(
          bill_id: 7,
          bill_code: "CGDD-1",
          supplier_name: "供应商",
          contact_person: "",
          phone: "",
          warehouse_name: "总仓",
          warehouse_id: 2,
          bill_date: "2026-10-01",
          status: 20,
          status_name: "待收货",
          total_qty: 8.0,
          total_money: 30.0,
          remark: "",
          ifred: 0,
        )
      use _ <- result_try(repo.upsert_inbound_summary(conn, summary, 504, "2026-10-01 00:00:00"))
      let assert Ok(stamp) = repo.get_inbound_stamp(conn, 7)
      let assert Some(stamp) = stamp
      stamp.detail_synced_at |> should.equal("")
      repo.needs_inbound_detail(Some(stamp), summary) |> should.be_true
      repo.save_inbound_detail(
        conn,
        PurchaseOrder(
          bill_id: 7,
          bill_code: "CGDD-1",
          supplier_name: "供应商",
          contact_person: "",
          phone: "",
          warehouse_name: "总仓",
          warehouse_id: 2,
          bill_date: "2026-10-01",
          status: 20,
          status_name: "待收货",
          total_qty: 8.0,
          total_money: 30.0,
          remark: "",
          items: [
            PurchaseOrderItem(
              bill_detail_id: 9,
              sku_id: 11,
              goods_code: "c001",
              goods_name: "商品",
              barcode: "690",
              spec: "",
              package_qty: 1.0,
              package_unit: "件",
              base_qty: 8.0,
              base_unit: "盒",
              price: 1.0,
              total_money: 8.0,
              position_code: "默认货位",
              area_code: "",
            ),
          ],
        ),
        504,
        "2026-10-01 00:00:01",
      )
    })
  let assert Ok(stored) =
    database.transaction(db, fn(conn) { repo.get_inbound(conn, 7) })
  let assert Some(order) = stored
  list.length(order.items) |> should.equal(1)
}

fn result_try(result: Result(a, b), next: fn(a) -> Result(c, b)) -> Result(c, b) {
  case result {
    Ok(value) -> next(value)
    Error(err) -> Error(err)
  }
}

pub fn receipt_stays_within_ordered_qty_and_enabled_location_test() {
  receiving.parse_day("2026-10-01") |> should.equal(Ok("2026-10-01"))
  receiving.parse_day("2026/10/01") |> should.be_error
  let path = "build/test-receipt.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_207, code: "00002", fullname: "总仓", easycode: "ZC"),
        True,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_208, code: "00003", fullname: "门店", easycode: "MD"),
        False,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.insert_location(
        conn,
        location.Location(
          warehouse_id: 793_207,
          area: "A",
          aisle: "01",
          level: "02",
          bin: "03",
          code: "A-01-02-03",
          barcode: "LOC-00002-A010203",
          enabled: True,
          created_at: "2026-10-23 12:00:00",
          updated_at: "2026-10-23 12:00:00",
        ),
      ))
      use _ <- result.try(repo.save_inbound_detail(
        conn,
        sample_purchase(793_207, 20),
        504,
        "2026-10-23 12:00:00",
      ))
      repo.save_inbound_detail(conn, sample_purchase(793_208, 20), 504, "2026-10-23 12:00:00")
    })
  let line = receiving.LineInput(9, 3.0, "B20261023", "2026-10-01", "2027-04-01", "a-01-02-03")
  let assert Ok(receipt) = receiving.create(db, 7, [line])
  list.length(receipt.lines) |> should.equal(1)
  let assert Ok(posted) = list.first(receipt.lines)
  posted.location_code |> should.equal("A-01-02-03")
  let over = receiving.LineInput(9, 6.0, "B2", "2026-10-01", "2027-04-01", "A-01-02-03")
  receiving.create(db, 7, [over]) |> should.be_error
  let assert Error(receiving.ReceiptRejected(_)) =
    receiving.create(db, 8, [receiving.LineInput(9, 1.0, "B2", "2026-10-01", "2027-04-01", "A-01-02-03")])
  let assert Ok(batches) = database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
  list.length(batches) |> should.equal(1)
  let assert Ok(batch) = list.first(batches)
  batch.qty |> should.equal(3.0)
  batch.location_code |> should.equal("A-01-02-03")
}

fn sample_purchase(warehouse_id: Int, status: Int) -> PurchaseOrder {
  let bill_id = case warehouse_id {
    793_207 -> 7
    _ -> 8
  }
  PurchaseOrder(
    bill_id: bill_id,
    bill_code: "CGDD-1",
    supplier_name: "供应商",
    contact_person: "",
    phone: "",
    warehouse_name: "总仓",
    warehouse_id: warehouse_id,
    bill_date: "2026-10-01",
    status: status,
    status_name: "待收货",
    total_qty: 8.0,
    total_money: 30.0,
    remark: "",
    items: [
      PurchaseOrderItem(
        bill_detail_id: 9,
        sku_id: 11,
        goods_code: "c001",
        goods_name: "商品",
        barcode: "690",
        spec: "",
        package_qty: 1.0,
        package_unit: "件",
        base_qty: 8.0,
        base_unit: "盒",
        price: 1.0,
        total_money: 8.0,
        position_code: "默认货位(总仓)",
        area_code: "默认库区",
      ),
    ],
  )
}

pub fn shipment_takes_earliest_expiry_first_test() {
  let later =
    repo.InventoryBatch(1, "A-01-02-04", 11, "c001", "LATE", "2026-10-01", "2027-01-01", 5.0)
  let earlier =
    repo.InventoryBatch(1, "A-01-02-03", 11, "c001", "EARLY", "2026-10-01", "2026-11-01", 2.0)
  let assert Ok(#(picks, _)) = shipping.allocate(3.0, [later, earlier])
  let assert [first, second] = picks
  first.batch_no |> should.equal("EARLY")
  first.qty |> should.equal(2.0)
  second.batch_no |> should.equal("LATE")
  second.qty |> should.equal(1.0)
  shipping.allocate(9.0, [earlier, later]) |> should.be_error

  let path = "build/test-shipment.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_207, code: "00002", fullname: "总仓", easycode: "ZC"),
        True,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_208, code: "00003", fullname: "门店", easycode: "MD"),
        False,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.save_order_detail(
        conn,
        sample_sales(793_207, 21),
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.save_order_detail(
        conn,
        sample_sales(793_208, 22),
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.add_inventory(
        conn,
        793_207,
        stock_line("EARLY", "2026-11-01", 2.0, "A-01-02-03"),
        "2026-10-23 12:00:00",
      ))
      repo.add_inventory(
        conn,
        793_207,
        stock_line("LATE", "2027-01-01", 5.0, "A-01-02-04"),
        "2026-10-23 12:00:00",
      )
    })
  let assert Ok(shipment) = shipping.create(db, 21, [shipping.ShipLine(9, 3.0)])
  let assert [first_line, second_line] = shipment.lines
  first_line.batch_no |> should.equal("EARLY")
  first_line.qty |> should.equal(2.0)
  first_line.location_code |> should.equal("A-01-02-03")
  second_line.batch_no |> should.equal("LATE")
  second_line.qty |> should.equal(1.0)
  let assert Ok(batches) =
    database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
  let assert [left] = batches
  left.batch_no |> should.equal("LATE")
  left.qty |> should.equal(4.0)
  let assert Error(shipping.ShipmentRejected("库存不足")) =
    shipping.create(db, 21, [shipping.ShipLine(9, 5.0)])
  let assert Error(shipping.ShipmentRejected("出库数量超过订单未发数量")) =
    shipping.create(db, 21, [shipping.ShipLine(9, 8.0)])
  let assert Error(shipping.ShipmentRejected("只能对总仓待发货的销售订单出库")) =
    shipping.create(db, 22, [shipping.ShipLine(9, 1.0)])
  let assert Error(shipping.ShipmentRejected("销售订单不存在")) =
    shipping.create(db, 1, [shipping.ShipLine(9, 1.0)])
  let assert Ok(still) =
    database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
  let assert [still_left] = still
  still_left.qty |> should.equal(4.0)

  let pending_ids = fn() {
    let assert Ok(page) =
      database.transaction(db, fn(conn) { repo.list_orders(conn, rbac.AllWarehouses, Some(20), 10, 0) })
    list.map(page.items, fn(order) { order.bill_id })
  }
  pending_ids() |> list.contains(21) |> should.be_true
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      repo.add_inventory(
        conn,
        793_207,
        stock_line("LATE", "2027-01-01", 3.0, "A-01-02-04"),
        "2026-10-23 12:00:00",
      )
    })
  let assert Ok(_) = shipping.create(db, 21, [shipping.ShipLine(9, 7.0)])
  pending_ids() |> list.contains(21) |> should.be_false
  let assert Ok(all) =
    database.transaction(db, fn(conn) { repo.list_orders(conn, rbac.AllWarehouses, None, 10, 0) })
  all.items |> list.any(fn(order) { order.bill_id == 21 }) |> should.be_true
  let assert Ok(True) =
    database.transaction(db, fn(conn) { repo.order_shipped_out(conn, 21) })
  let assert Ok(False) =
    database.transaction(db, fn(conn) { repo.order_shipped_out(conn, 22) })
}

pub fn purchase_receipt_gets_a_local_batch_code_test() {
  batch_code.for_detail(9) |> should.equal("BC-9")
  let path = "build/test-batch-code.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      repo.save_inbound_detail(conn, sample_purchase(793_207, 20), 501, "2026-10-23 12:00:00")
    })
  let assert Ok(codes) =
    database.transaction(db, fn(conn) { repo.list_batch_codes(conn, 7) })
  codes |> should.equal([#(9, "BC-9")])
  let assert Ok(stock) =
    database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
  stock |> should.equal([])
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      repo.save_inbound_detail(conn, sample_purchase(793_207, 20), 501, "2026-10-23 13:00:00")
    })
  let assert Ok(again) =
    database.transaction(db, fn(conn) { repo.list_batch_codes(conn, 7) })
  again |> should.equal([#(9, "BC-9")])
  let assert Ok(orders) =
    database.transaction(db, fn(conn) { repo.list_inbounds(conn, rbac.AllWarehouses, 504, Some(20), 10, 0) })
  orders.total |> should.equal(0)
  let assert Ok(receipts) =
    database.transaction(db, fn(conn) { repo.list_inbounds(conn, rbac.AllWarehouses, 501, Some(20), 10, 0) })
  receipts.total |> should.equal(1)
}

pub fn putaway_posts_batch_code_onto_enabled_location_test() {
  let path = "build/test-putaway.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)
  let store = sample_purchase(793_208, 20)
  let assert Ok(store_line) = list.first(store.items)
  let store =
    PurchaseOrder(..store, items: [PurchaseOrderItem(..store_line, bill_detail_id: 99)])
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_207, code: "00002", fullname: "总仓", easycode: "ZC"),
        True,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_208, code: "00003", fullname: "门店", easycode: "MD"),
        False,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.insert_location(conn, location_row(793_207, "A-01-02-03")))
      use _ <- result.try(repo.insert_location(conn, location_row(793_207, "A-01-02-04")))
      use _ <- result.try(repo.save_inbound_detail(
        conn,
        sample_purchase(793_207, 20),
        501,
        "2026-10-23 12:00:00",
      ))
      repo.save_inbound_detail(conn, store, 501, "2026-10-23 12:00:00")
    })
  let input =
    putaway.PutawayInput("bc-9", 3.0, "2026-10-01", "2027-04-01", "a-01-02-03")
  let assert Ok(row) = putaway.create(db, input)
  let assert Ok(target) = putaway.suggest(db, "bc-9", "")
  target.suggestion.location_code |> should.equal("A-01-02-03")
  target.suggestion.reason |> should.equal("该批次已在此库位,继续集中存放")
  target.produced_on |> should.equal("2026-10-01")
  row.batch_code |> should.equal("BC-9")
  row.location_code |> should.equal("A-01-02-03")
  row.qty |> should.equal(3.0)
  let assert Ok(batches) =
    database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
  let assert [batch] = batches
  batch.batch_no |> should.equal("BC-9")
  batch.qty |> should.equal(3.0)
  batch.expires_on |> should.equal("2027-04-01")
  let assert Error(putaway.PutawayRejected("上架数量超过采购入库单未上架数量")) =
    putaway.create(db, putaway.PutawayInput("BC-9", 6.0, "2026-10-01", "2027-04-01", "A-01-02-03"))
  let assert Error(putaway.PutawayRejected("同一批次码的生产日期和到期日必须一致")) =
    putaway.create(db, putaway.PutawayInput("BC-9", 1.0, "2026-10-02", "2027-04-01", "A-01-02-04"))
  let assert Ok(_) =
    putaway.create(db, putaway.PutawayInput("BC-9", 2.0, "2026-10-01", "2027-04-01", "A-01-02-04"))
  let assert Error(putaway.PutawayRejected("该仓库只作显示,不能上架")) =
    putaway.create(db, putaway.PutawayInput("BC-99", 1.0, "2026-10-01", "2027-04-01", "A-01-02-03"))
  let assert Error(putaway.PutawayRejected("库位不存在或未启用")) =
    putaway.create(db, putaway.PutawayInput("BC-9", 1.0, "2026-10-01", "2027-04-01", "B-01-01-01"))
  let assert Error(putaway.PutawayRejected("批次码不存在")) =
    putaway.create(db, putaway.PutawayInput("BC-1", 1.0, "2026-10-01", "2027-04-01", "A-01-02-03"))
  let assert Ok(stock) =
    database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
  list.length(stock) |> should.equal(2)
}

fn stock_at(
  location: String,
  goods: String,
  batch: String,
  expires: String,
  qty: Float,
) -> repo.InventoryBatch {
  repo.InventoryBatch(
    warehouse_id: 1,
    location_code: location,
    sku_id: 1,
    goods_code: goods,
    batch_no: batch,
    produced_on: "2026-01-01",
    expires_on: expires,
    qty: qty,
  )
}

pub fn putaway_recommendation_keeps_same_batch_and_fifo_test() {
  let locations = ["A-01-01-01", "A-01-01-02", "A-01-01-03", "A-01-01-04"]
  let held = [stock_at("A-01-01-02", "c1", "BC-1", "2026-06-01", 4.0)]
  putaway.recommend(locations, held, "c1", 1, "BC-1", "2026-06-01").location_code
  |> should.equal("A-01-01-02")
  putaway.recommend(locations, held, "c1", 1, "BC-2", "2026-06-01").location_code
  |> should.equal("A-01-01-02")
  putaway.recommend(locations, held, "c1", 1, "BC-2", "2026-12-01").location_code
  |> should.equal("A-01-01-03")
  putaway.recommend(locations, held, "c1", 1, "BC-2", "2026-03-01").location_code
  |> should.equal("A-01-01-01")
  putaway.recommend(locations, [], "c1", 1, "BC-9", "").location_code
  |> should.equal("A-01-01-01")
  putaway.recommend(["A-01-01-02"], held, "c9", 9, "BC-9", "2027-01-01").location_code
  |> should.equal("")
  putaway.recommend([], [], "c1", 1, "BC-9", "").reason
  |> should.equal("没有可推荐的库位")
}

pub fn wave_groups_orders_by_expiry_then_walks_locations_test() {
  let early =
    repo.InventoryBatch(1, "A-01-01-02", 11, "c001", "EARLY", "2026-10-01", "2026-11-01", 6.0)
  let late =
    repo.InventoryBatch(1, "A-01-01-01", 11, "c001", "LATE", "2026-10-01", "2027-01-01", 10.0)
  let lines =
    wave.plan(
      [
        wave.Demand(21, "SO-21", 101, 11, "c001", "商品", "690", "", "盒", 8.0),
        wave.Demand(31, "SO-31", 102, 11, "c001", "商品", "690", "", "盒", 4.0),
        wave.Demand(31, "SO-31", 201, 12, "c002", "另一种", "691", "", "盒", 2.0),
      ],
      [late, early],
    )
  let assert [first, second, short] = lines
  first.line_no |> should.equal(1)
  first.location_code |> should.equal("A-01-01-01")
  first.batch_no |> should.equal("LATE")
  first.qty |> should.equal(6.0)
  let assert [late_a, late_b] = first.allocations
  late_a.bill_id |> should.equal(21)
  late_a.qty |> should.equal(2.0)
  late_b.bill_id |> should.equal(31)
  late_b.qty |> should.equal(4.0)
  second.location_code |> should.equal("A-01-01-02")
  second.batch_no |> should.equal("EARLY")
  second.qty |> should.equal(6.0)
  let assert [early_only] = second.allocations
  early_only.bill_id |> should.equal(21)
  short.location_code |> should.equal("")
  short.goods_code |> should.equal("c002")
  short.qty |> should.equal(0.0)
  short.short_qty |> should.equal(2.0)

  let path = "build/test-wave.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Error(wave.WaveRejected("至少选择一张订单")) =
    wave.create(db, [], "clerk", 793_207)
  let assert Error(wave.WaveRejected("订单重复")) =
    wave.create(db, [21, 21], "clerk", 793_207)
  let assert Ok(_) = schema.migrate(db)
  let assert [item] = sample_sales(793_207, 21).items
  let order21 =
    SalesOrder(
      ..sample_sales(793_207, 21),
      bill_code: "SO-21",
      items: [SalesOrderItem(..item, bill_detail_id: 101, base_qty: 8.0)],
    )
  let order31 =
    SalesOrder(
      ..sample_sales(793_207, 31),
      bill_code: "SO-31",
      bill_date: "2026-10-02",
      items: [
        SalesOrderItem(..item, bill_detail_id: 102, base_qty: 4.0),
        SalesOrderItem(
          ..item,
          bill_detail_id: 201,
          sku_id: 12,
          goods_code: "c002",
          goods_name: "另一种",
          barcode: "691",
          base_qty: 2.0,
        ),
      ],
    )
  let order41 =
    SalesOrder(
      ..sample_sales(793_207, 41),
      bill_code: "SO-41",
      bill_date: "2026-10-03",
      items: [SalesOrderItem(..item, bill_detail_id: 301, base_qty: 5.0)],
    )
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_207, code: "00002", fullname: "总仓", easycode: "ZC"),
        True,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_208, code: "00003", fullname: "门店", easycode: "MD"),
        False,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.save_order_detail(conn, order21, "2026-10-23 12:00:00"))
      use _ <- result.try(repo.save_order_detail(conn, order31, "2026-10-23 12:00:00"))
      use _ <- result.try(repo.save_order_detail(conn, order41, "2026-10-23 12:00:00"))
      use _ <- result.try(repo.save_order_detail(
        conn,
        SalesOrder(..sample_sales(793_208, 22), bill_code: "SO-22"),
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.add_inventory(
        conn,
        793_207,
        stock_line("EARLY", "2026-11-01", 6.0, "A-01-01-02"),
        "2026-10-23 12:00:00",
      ))
      repo.add_inventory(
        conn,
        793_207,
        stock_line("LATE", "2027-01-01", 10.0, "A-01-01-01"),
        "2026-10-23 12:00:00",
      )
    })
  let candidate_ids = fn() {
    let assert Ok(page) =
      database.transaction(db, fn(conn) {
        repo.list_wave_candidates(conn, rbac.AllWarehouses, 793_207, 20, 0)
      })
    list.map(page.items, fn(order) { order.bill_id })
  }
  candidate_ids() |> should.equal([21, 31, 41])
  let assert Ok(grouped) = wave.create(db, [31, 21], "clerk", 793_207)
  let assert [late_line, early_line, gap] = grouped.picks
  late_line.batch_no |> should.equal("LATE")
  late_line.qty |> should.equal(6.0)
  early_line.batch_no |> should.equal("EARLY")
  early_line.qty |> should.equal(6.0)
  let assert [early_alloc] = early_line.allocations
  early_alloc.bill_code |> should.equal("SO-21")
  gap.short_qty |> should.equal(2.0)
  let assert Ok(saved) = wave.get(db, grouped.id)
  saved.orders |> list.map(fn(order) { order.bill_id }) |> should.equal([21, 31])
  saved.picks |> list.length |> should.equal(3)
  let assert Ok(stock) =
    database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
  let assert [early_stock, late_stock] = stock
  early_stock.qty |> should.equal(6.0)
  late_stock.qty |> should.equal(10.0)
  let assert Error(wave.WaveRejected("订单已在未完成的波次里")) =
    wave.create(db, [21], "clerk", 793_207)
  candidate_ids() |> should.equal([41])
  let assert Ok(held) = wave.create(db, [41], "clerk", 793_207)
  let assert [held_pick, held_gap] = held.picks
  held_pick.batch_no |> should.equal("LATE")
  held_pick.location_code |> should.equal("A-01-01-01")
  held_pick.qty |> should.equal(4.0)
  held_gap.short_qty |> should.equal(1.0)
  let assert Ok(#(total, listed)) = wave.list(db, 793_207, 10, 0)
  total |> should.equal(2)
  let assert [newest, _] = listed
  newest.id |> should.equal(held.id)
  newest.short_qty |> should.equal(1.0)
  let assert Ok(still) =
    database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
  let assert [still_early, still_late] = still
  still_early.qty |> should.equal(6.0)
  still_late.qty |> should.equal(10.0)
  let assert Ok(cancelled) = wave.cancel(db, grouped.id, 793_207)
  cancelled.status |> should.equal("cancelled")
  let assert Ok(_) = wave.cancel(db, held.id, 793_207)
  let assert Error(wave.WaveRejected("只能撤销未完成的波次")) =
    wave.cancel(db, grouped.id, 793_207)
  candidate_ids() |> should.equal([21, 31, 41])
  let assert Error(wave.WaveRejected("只能把总仓待发货的订单组成波次")) =
    wave.create(db, [22], "clerk", 793_208)
  let assert Error(wave.WaveRejected("单据不属于当前作业仓库")) =
    wave.create(db, [21], "clerk", 793_208)
}

pub fn stock_move_keeps_wave_reserved_qty_in_place_test() {
  let input = fn(qty, to_location) {
    stock_move.MoveInput(
      from_location: "a-01-01-01",
      sku_id: 11,
      batch_no: "EARLY",
      produced_on: "2026-10-01",
      expires_on: "2026-11-01",
      qty: qty,
      to_location: to_location,
    )
  }
  let path = "build/test-move.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Error(stock_move.MoveRejected("移库数量必须大于 0")) =
    stock_move.create(db, 793_207, input(0.0, "A-01-01-02"), "clerk")
  let assert Error(stock_move.MoveRejected("目标库位不能和源库位相同")) =
    stock_move.create(db, 793_207, input(1.0, "A-01-01-01"), "clerk")
  let assert Ok(_) = schema.migrate(db)
  let assert [item] = sample_sales(793_207, 21).items
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_207, code: "00002", fullname: "总仓", easycode: "ZC"),
        True,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_208, code: "00003", fullname: "门店", easycode: "MD"),
        False,
        "2026-10-23 12:00:00",
      ))
      use _ <- result.try(repo.insert_location(conn, move_bin(793_207, "A-01-01-01", True)))
      use _ <- result.try(repo.insert_location(conn, move_bin(793_207, "A-01-01-02", True)))
      use _ <- result.try(repo.insert_location(conn, move_bin(793_207, "A-01-01-03", False)))
      use _ <- result.try(repo.save_order_detail(
        conn,
        SalesOrder(
          ..sample_sales(793_207, 21),
          bill_code: "SO-21",
          items: [SalesOrderItem(..item, base_qty: 6.0)],
        ),
        "2026-10-23 12:00:00",
      ))
      repo.add_inventory(
        conn,
        793_207,
        stock_line("EARLY", "2026-11-01", 10.0, "A-01-01-01"),
        "2026-10-23 12:00:00",
      )
    })
  let assert Error(stock_move.MoveRejected("只能在作业仓库移库")) =
    stock_move.create(db, 793_208, input(1.0, "A-01-01-02"), "clerk")
  let assert Error(stock_move.MoveRejected("仓库不存在")) =
    stock_move.create(db, 9, input(1.0, "A-01-01-02"), "clerk")
  let assert Error(stock_move.MoveRejected("目标库位不存在")) =
    stock_move.create(db, 793_207, input(1.0, "A-01-01-09"), "clerk")
  let assert Error(stock_move.MoveRejected("目标库位已停用")) =
    stock_move.create(db, 793_207, input(1.0, "A-01-01-03"), "clerk")
  let assert Ok(task) = wave.create(db, [21], "clerk", 793_207)
  let assert Error(stock_move.MoveRejected("这批库存已被未完成的波次占用")) =
    stock_move.create(db, 793_207, input(5.0, "A-01-01-02"), "clerk")
  let assert Ok(moved) =
    stock_move.create(db, 793_207, input(4.0, "A-01-01-02"), "clerk")
  moved.from_location |> should.equal("A-01-01-01")
  moved.to_location |> should.equal("A-01-01-02")
  moved.qty |> should.equal(4.0)
  moved.goods_code |> should.equal("c001")
  let assert Error(stock_move.MoveRejected("这批库存已被未完成的波次占用")) =
    stock_move.create(db, 793_207, input(1.0, "A-01-01-02"), "clerk")
  let quantities = fn() {
    let assert Ok(rows) =
      database.transaction(db, fn(conn) { repo.list_inventory(conn, 793_207) })
    list.map(rows, fn(row) { #(row.location_code, row.qty) })
  }
  quantities() |> should.equal([#("A-01-01-01", 6.0), #("A-01-01-02", 4.0)])
  let assert Ok(_) = wave.cancel(db, task.id, 793_207)
  let assert Ok(rest) =
    stock_move.create(db, 793_207, input(6.0, "A-01-01-02"), "clerk")
  rest.qty |> should.equal(6.0)
  quantities() |> should.equal([#("A-01-01-02", 10.0)])
  let assert Ok(#(total, rows)) = stock_move.list(db, 793_207, 10, 0)
  total |> should.equal(2)
  rows |> list.any(fn(row) { row.qty == 6.0 }) |> should.be_true
  rows |> list.any(fn(row) { row.qty == 4.0 }) |> should.be_true
}

fn move_bin(warehouse_id: Int, code: String, enabled: Bool) -> location.Location {
  location.Location(
    warehouse_id: warehouse_id,
    area: "A",
    aisle: "01",
    level: "01",
    bin: "01",
    code: code,
    barcode: "LOC-" <> code,
    enabled: enabled,
    created_at: "2026-10-23 12:00:00",
    updated_at: "2026-10-23 12:00:00",
  )
}

pub fn location_plan_previews_then_creates_test() {
  let path = "build/test-location-plan.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_207, code: "00002", fullname: "总仓", easycode: "ZC"),
        True,
        "2026-10-23 12:00:00",
      ))
      repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 793_208, code: "00003", fullname: "门店", easycode: "MD"),
        False,
        "2026-10-23 12:00:00",
      )
    })
  let plan =
    location_plan.Plan(
      warehouse_id: 793_207,
      areas: ["a"],
      aisle: location_plan.Range(1, 2, 2),
      level: location_plan.Range(1, 2, 2),
      bin: location_plan.Range(1, 3, 2),
    )
  let assert Ok(preview) = location_plan.run(db, plan, False)
  list.length(preview.created) |> should.equal(12)
  let assert Ok(first) = list.first(preview.created)
  first.code |> should.equal("A-01-01-01")
  first.barcode |> should.equal("LOC-00002-A010101")
  let assert Ok(none) =
    database.transaction(db, fn(conn) { repo.list_locations(conn, 793_207) })
  none |> should.equal([])

  let assert Ok(made) = location_plan.run(db, plan, True)
  list.length(made.created) |> should.equal(12)
  let wider = location_plan.Plan(..plan, bin: location_plan.Range(1, 4, 2))
  let assert Ok(more) = location_plan.run(db, wider, True)
  list.length(more.created) |> should.equal(4)
  list.length(more.existing) |> should.equal(12)
  let assert Ok(rows) =
    database.transaction(db, fn(conn) { repo.list_locations(conn, 793_207) })
  list.length(rows) |> should.equal(16)

  let assert Error(location_plan.PlanRejected("该仓库只作显示,不能建立库存库位")) =
    location_plan.run(db, location_plan.Plan(..plan, warehouse_id: 793_208), True)
  let assert Error(location_plan.PlanMissingWarehouse) =
    location_plan.run(db, location_plan.Plan(..plan, warehouse_id: 1), True)
  let assert Error(location_plan.PlanRejected(_)) =
    location_plan.run(db, location_plan.Plan(..plan, bin: location_plan.Range(5, 1, 2)), False)
  let assert Error(location_plan.PlanRejected(_)) =
    location_plan.run(db, location_plan.Plan(..plan, bin: location_plan.Range(1, 100, 2)), False)
  let assert Error(location_plan.PlanRejected(_)) =
    location_plan.run(
      db,
      location_plan.Plan(..plan, aisle: location_plan.Range(1, 99, 2), level: location_plan.Range(1, 99, 2)),
      False,
    )
}

fn location_row(warehouse_id: Int, code: String) -> location.Location {
  location.Location(
    warehouse_id: warehouse_id,
    area: "A",
    aisle: "01",
    level: "02",
    bin: "03",
    code: code,
    barcode: "LOC-00002-" <> code,
    enabled: True,
    created_at: "2026-10-23 12:00:00",
    updated_at: "2026-10-23 12:00:00",
  )
}

fn sample_sales(warehouse_id: Int, bill_id: Int) -> SalesOrder {
  SalesOrder(
    bill_id: bill_id,
    bill_code: "XSDD-TEST",
    customer_name: "客户",
    contact_person: "",
    phone: "",
    warehouse_name: "总仓",
    warehouse_id: warehouse_id,
    bill_date: "2026-10-01",
    status: 20,
    status_name: "待发货",
    total_qty: 10.0,
    total_money: 20.0,
    remark: "",
    items: [
      SalesOrderItem(
        bill_detail_id: 9,
        sku_id: 11,
        goods_code: "c001",
        goods_name: "商品",
        barcode: "690",
        spec: "",
        package_qty: 1.0,
        package_unit: "件",
        base_qty: 10.0,
        base_unit: "盒",
        price: 2.0,
        total_money: 20.0,
        position_code: "默认货位(总仓)",
        area_code: "",
      ),
    ],
  )
}

fn stock_line(
  batch_no: String,
  expires_on: String,
  qty: Float,
  location_code: String,
) -> ReceiptLine {
  ReceiptLine(
    line_no: 1,
    bill_detail_id: 1,
    sku_id: 11,
    goods_code: "c001",
    goods_name: "商品",
    barcode: "690",
    received_qty: qty,
    batch_no: batch_no,
    produced_on: "2026-10-01",
    expires_on: expires_on,
    location_code: location_code,
  )
}

pub fn location_code_uses_four_levels_test() {
  let assert Ok(code) = location.build("a", "01", "02", "03")
  code.code |> should.equal("A-01-02-03")
  location.barcode("00002", code.compact) |> should.equal("LOC-00002-A010203")
  location.build("默认", "01", "02", "03") |> should.be_error
  location.build("", "01", "02", "03") |> should.be_error
}

pub fn location_roundtrip_does_not_seed_rows_test() {
  let path = "build/test-location.sqlite"
  let _ = simplifile.delete(path)
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)
  let assert Ok(empty) =
    database.transaction(db, fn(conn) { repo.list_locations(conn, 793_207) })
  empty |> list.length |> should.equal(0)
  let assert Ok(code) = location.build("A", "01", "02", "03")
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      repo.insert_location(
        conn,
        location.Location(
          warehouse_id: 793_207,
          area: code.area,
          aisle: code.aisle,
          level: code.level,
          bin: code.bin,
          code: code.code,
          barcode: location.barcode("00002", code.compact),
          enabled: True,
          created_at: "2026-10-23 12:00:00",
          updated_at: "2026-10-23 12:00:00",
        ),
      )
    })
  let assert Ok(rows) =
    database.transaction(db, fn(conn) { repo.list_locations(conn, 793_207) })
  list.length(rows) |> should.equal(1)
  let assert Ok(row) = list.first(rows)
  row.barcode |> should.equal("LOC-00002-A010203")
  let assert Ok(True) =
    database.transaction(db, fn(conn) {
      repo.set_location_enabled(
        conn,
        793_207,
        "A-01-02-03",
        False,
        "2026-10-23 12:01:00",
      )
    })
}

pub fn stock_scope_keeps_main_warehouse_only_test() {
  let tokens = warehouse_scope.parse_tokens("总仓,00003")
  tokens |> should.equal(["总仓", "00003"])
  let warehouses = [
    QlWarehouse(id: 1, code: "00002", fullname: "总仓", easycode: "ZC"),
    QlWarehouse(id: 2, code: "00003", fullname: "门店", easycode: "MD"),
    QlWarehouse(id: 3, code: "C2", fullname: "C2", easycode: "C2"),
  ]
  warehouse_scope.stock_warehouse_ids(warehouses, ["总仓"])
  |> should.equal([1])
  warehouse_scope.bill_in_stock_scope(2, "门店", warehouses, ["总仓"])
  |> should.be_false
  warehouse_scope.bill_in_stock_scope(3, "C2", warehouses, ["总仓"])
  |> should.be_false
  warehouse_scope.is_stock_warehouse("00002", "ZC", "总仓", ["ZC"])
  |> should.be_true
}

pub fn ql361_pace_keeps_a_gap_test() {
  pace.wait_ms(5_000, 0, 2_000) |> should.equal(0)
  pace.wait_ms(5_000, 4_500, 2_000) |> should.equal(1_500)
  pace.wait_ms(8_000, 4_500, 2_000) |> should.equal(0)
  config.clamp_poll_seconds(30) |> should.equal(300)
  config.clamp_poll_seconds(600) |> should.equal(600)
  config.clamp_request_gap_ms(100) |> should.equal(1000)
  config.clamp_request_gap_ms(3_000) |> should.equal(3_000)
}

pub fn local_clock_format_test() {
  clock_now() |> string.length |> should.equal(19)
  clock_today() |> string.length |> should.equal(10)
}

pub fn password_hash_verifies_only_the_same_secret_test() {
  let stored = password.hash("correct horse")
  password.verify("correct horse", stored) |> should.be_true
  password.verify("correct horsf", stored) |> should.be_false
  password.verify("correct horse", "plain-text") |> should.be_false
  { stored == password.hash("correct horse") } |> should.be_false
  password.validate("short") |> result.is_error |> should.be_true
}

pub fn warehouse_scope_filter_sql_test() {
  rbac.sql_filter(rbac.AllWarehouses, "warehouse_id").0 |> should.equal("1 = 1")
  rbac.sql_filter(rbac.Warehouses([]), "warehouse_id").0 |> should.equal("1 = 0")
  let #(sql, params) = rbac.sql_filter(rbac.Warehouses([1, 2]), "warehouse_id")
  sql |> should.equal("warehouse_id IN (?, ?)")
  list.length(params) |> should.equal(2)
  rbac.scope_for([rbac.sys_admin], []) |> should.equal(rbac.AllWarehouses)
  rbac.scope_for(["ROLE_OPERATOR"], [7]) |> rbac.allows(7) |> should.be_true
  rbac.scope_for(["ROLE_OPERATOR"], [7]) |> rbac.allows(8) |> should.be_false
}

fn auth_db(name: String) -> database.Database {
  let path = "build/test-" <> name <> ".sqlite"
  let _ = simplifile.delete(path)
  let _ = simplifile.delete(path <> "-wal")
  let _ = simplifile.delete(path <> "-shm")
  let cfg =
    config.Config(
      ql361_username: "",
      ql361_password: "",
      ql361_base_url: "",
      order_poll_ms: 300_000,
      request_gap_ms: 2000,
      order_begin_date: "2024-01-01",
      order_end_offset_days: 1,
      db_driver: config.Sqlite,
      database_path: path,
      database_url: "",
      http_port: 8080,
      stock_warehouses: ["总仓"],
    )
  let assert Ok(db) = database.start(cfg)
  let assert Ok(_) = schema.migrate(db)
  let assert Ok(_) =
    database.transaction(db, fn(conn) {
      use _ <- result.try(repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 1, code: "00002", fullname: "总仓", easycode: "ZC"),
        True,
        "2026-10-23 13:00:00",
      ))
      repo.upsert_warehouse(
        conn,
        QlWarehouse(id: 2, code: "C2", fullname: "C2", easycode: "C2"),
        False,
        "2026-10-23 13:00:00",
      )
    })
  db
}

fn auth_cfg(admin_password: String) -> config.AuthConfig {
  config.AuthConfig(
    session_hours: 12,
    max_failed_logins: 3,
    lockout_minutes: 15,
    admin_username: "Admin",
    admin_password: admin_password,
  )
}

pub fn bootstrap_admin_only_once_test() {
  let db = auth_db("auth-bootstrap")
  access.bootstrap_admin(db, auth_cfg("")) |> should.equal(Ok(access.AdminNotConfigured))
  access.bootstrap_admin(db, auth_cfg("short")) |> result.is_error |> should.be_true
  access.bootstrap_admin(db, auth_cfg("admin-secret-1"))
  |> should.equal(Ok(access.AdminCreated("admin")))
  access.bootstrap_admin(db, auth_cfg("admin-secret-2"))
  |> should.equal(Ok(access.UsersExist))
  let cfg = auth_cfg("")
  let assert Error(access.Unauthorized(_)) =
    access.login(db, cfg, "admin", "admin-secret-2", "web")
  let assert Ok(#(token, admin)) = access.login(db, cfg, " ADMIN ", "admin-secret-1", "web")
  admin.scope |> should.equal(rbac.AllWarehouses)
  access.has(admin, rbac.user_manage) |> should.be_true
  let assert Ok(again) = access.authenticate(db, token)
  again.user_id |> should.equal(admin.user_id)
  let assert Error(access.Forbidden(_)) =
    access.login(db, cfg, "admin", "admin-secret-1", "mobile")
  let assert Error(access.Rejected(_)) =
    access.login(db, cfg, "admin", "admin-secret-1", "pda")
}

pub fn operator_is_limited_to_its_warehouse_test() {
  let db = auth_db("auth-operator")
  let cfg = auth_cfg("admin-secret-1")
  let assert Ok(_) = access.bootstrap_admin(db, cfg)
  let assert Ok(#(_, admin)) = access.login(db, cfg, "admin", "admin-secret-1", "web")
  let input =
    access.UserInput(
      display_name: "张三",
      enabled: True,
      roles: ["ROLE_OPERATOR"],
      warehouse_ids: [1],
    )
  let assert Error(access.Rejected(_)) =
    access.create_user(db, admin, "op1", "op-secret-1", access.UserInput(..input, warehouse_ids: []))
  let assert Error(access.Rejected(_)) =
    access.create_user(db, admin, "op1", "op-secret-1", access.UserInput(..input, roles: ["ROLE_X"]))
  let assert Ok(user) = access.create_user(db, admin, "op1", "op-secret-1", input)
  user.warehouse_ids |> should.equal([1])
  let assert Error(access.Rejected(_)) =
    access.create_user(db, admin, "OP1", "op-secret-1", input)

  let assert Error(access.Forbidden(_)) =
    access.login(db, cfg, "op1", "op-secret-1", "web")
  let assert Ok(#(token, op)) = access.login(db, cfg, "op1", "op-secret-1", "mobile")
  op.current_warehouse |> should.equal(1)
  op.scope |> should.equal(rbac.Warehouses([1]))
  access.has(op, rbac.putaway_execute) |> should.be_true
  access.has(op, rbac.user_manage) |> should.be_false
  access.has(op, rbac.sync_run) |> should.be_false
  let assert Error(access.Forbidden(_)) = access.select_warehouse(db, op, 2)

  let assert Ok(page) =
    database.transaction(db, fn(conn) {
      repo.list_orders(conn, rbac.Warehouses([]), None, 10, 0)
    })
  page.total |> should.equal(0)

  let assert Ok(_) =
    access.update_user(db, admin, user.id, access.UserInput(..input, enabled: False))
  let assert Error(access.Unauthorized(_)) = access.authenticate(db, token)
  let assert Error(access.Forbidden(_)) =
    access.login(db, cfg, "op1", "op-secret-1", "mobile")
  let assert Error(access.Rejected(_)) =
    access.update_user(
      db,
      admin,
      admin.user_id,
      access.UserInput(..input, roles: ["ROLE_WH_MANAGER"]),
    )
}

pub fn repeated_wrong_passwords_lock_the_account_test() {
  let db = auth_db("auth-lockout")
  let cfg = auth_cfg("admin-secret-1")
  let assert Ok(_) = access.bootstrap_admin(db, cfg)
  let assert Error(access.Unauthorized(_)) = access.login(db, cfg, "admin", "wrong-1", "web")
  let assert Error(access.Unauthorized(_)) = access.login(db, cfg, "admin", "wrong-2", "web")
  let assert Error(access.Unauthorized(message)) =
    access.login(db, cfg, "admin", "wrong-3", "web")
  string.contains(message, "锁定") |> should.be_true
  let assert Error(access.Unauthorized(_)) =
    access.login(db, cfg, "admin", "admin-secret-1", "web")
}

pub fn logout_and_password_change_revoke_sessions_test() {
  let db = auth_db("auth-logout")
  let cfg = auth_cfg("admin-secret-1")
  let assert Ok(_) = access.bootstrap_admin(db, cfg)
  let assert Ok(#(t1, s1)) = access.login(db, cfg, "admin", "admin-secret-1", "web")
  let assert Ok(#(t2, s2)) = access.login(db, cfg, "admin", "admin-secret-1", "desktop")
  let assert Error(access.Rejected(_)) =
    access.change_password(db, s1, "bad-old-pass", "admin-secret-9")
  let assert Ok(_) = access.change_password(db, s1, "admin-secret-1", "admin-secret-9")
  let assert Ok(_) = access.authenticate(db, t1)
  let assert Error(access.Unauthorized(_)) = access.authenticate(db, t2)
  let assert Ok(_) = access.logout(db, s1)
  let assert Error(access.Unauthorized(_)) = access.authenticate(db, t1)
  let assert Error(access.Unauthorized(_)) = access.authenticate(db, "")
  let _ = s2
  let assert Ok(#(total, logs)) =
    database.transaction(db, fn(conn) { user_repo.list_audit(conn, 50, 0) })
  { total >= 4 } |> should.be_true
  list.any(logs, fn(entry: user_repo.AuditEntry) { entry.action == "auth.change_password" })
  |> should.be_true
}

@external(erlang, "swiftflow_clock_ffi", "now_local")
fn clock_now() -> String

@external(erlang, "swiftflow_clock_ffi", "today_local")
fn clock_today() -> String

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/IaC/SwiftFlow

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.