Artifact f9ff9bb700bd5a576c0808ab2aff7f8abf08417c5992b2ac8f86d0bf56e09630:
- File
backend/test/swiftflow_backend_test.gleam
— part of check-in
[bee9efb316]
at
2026-10-23 06:37:04
on branch trunk
— 同一仓库内移库,并留下波次已占用的数量
- 把一个批次从源库位转到启用的目标库位,库存立刻变更 - 未完成波次占用的数量不能移走,撤销波次后才能继续移 - 移库记录留在本地,不写回来肯 (user: pluto size: 51398)
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