Artifact 51a0c08343026d50b2cd68256ed3e67660a32edd6c5ec396c38d02ecfeb9f888:
- File
backend/src/stock_move.gleam
— part of check-in
[bee9efb316]
at
2026-10-23 06:37:04
on branch trunk
— 同一仓库内移库,并留下波次已占用的数量
- 把一个批次从源库位转到启用的目标库位,库存立刻变更 - 未完成波次占用的数量不能移走,撤销波次后才能继续移 - 移库记录留在本地,不写回来肯 (user: pluto size: 12165)
import clock import database.{ type Conn, type Database, type DbError, FloatParam, IntParam, TextParam, } import gleam/dynamic/decode import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import ql361api/json_codec import repo.{type InventoryBatch, type ReceiptLine, InventoryBatch, ReceiptLine} import wisp pub type MoveError { MoveRejected(String) MoveDb(DbError) } pub type MoveInput { MoveInput( from_location: String, sku_id: Int, batch_no: String, produced_on: String, expires_on: String, qty: Float, to_location: String, ) } pub type Move { Move( id: String, warehouse_id: Int, from_location: String, to_location: String, sku_id: Int, goods_code: String, batch_no: String, produced_on: String, expires_on: String, qty: Float, created_by: String, created_at: String, ) } pub fn create( db: Database, warehouse_id: Int, input: MoveInput, created_by: String, ) -> Result(Move, MoveError) { let from_location = normalize_code(input.from_location) let to_location = normalize_code(input.to_location) let batch_no = string.trim(input.batch_no) case from_location == "" { True -> Error(MoveRejected("需要源库位")) False -> case to_location == "" { True -> Error(MoveRejected("需要目标库位")) False -> case batch_no == "" { True -> Error(MoveRejected("需要批次号")) False -> case from_location == to_location { True -> Error(MoveRejected("目标库位不能和源库位相同")) False -> case input.qty >. 0.0 { False -> Error(MoveRejected("移库数量必须大于 0")) True -> { let created_at = clock.now_local() let move_id = "MV-" <> compact(created_at) <> "-" <> wisp.random_string(4) let input = MoveInput( ..input, from_location: from_location, to_location: to_location, batch_no: batch_no, produced_on: string.trim(input.produced_on), expires_on: string.trim(input.expires_on), ) case database.transaction(db, fn(conn) { transfer(conn, move_id, warehouse_id, input, created_by, created_at) }) { Ok(row) -> Ok(row) Error(database.DbError(message)) -> Error(classify(message)) } } } } } } } } pub fn list( db: Database, warehouse_id: Int, limit: Int, offset: Int, ) -> Result(#(Int, List(Move)), MoveError) { case database.transaction(db, fn(conn) { list_rows(conn, warehouse_id, limit, offset) }) { Ok(page) -> Ok(page) Error(database.DbError(message)) -> Error(classify(message)) } } fn transfer( conn: Conn, move_id: String, warehouse_id: Int, input: MoveInput, created_by: String, created_at: String, ) -> Result(Move, DbError) { use stored <- result.try(repo.get_warehouse(conn, warehouse_id)) case stored { None -> Error(database.DbError("仓库不存在")) Some(stored) -> case stored.operational { False -> Error(database.DbError("只能在作业仓库移库")) True -> { use source <- result.try(find_batch(conn, warehouse_id, input)) case source { None -> Error(database.DbError("源库位没有这个批次")) Some(source) -> { use reserved <- result.try(reserved_qty(conn, source)) let free = source.qty -. reserved case input.qty <=. free +. 0.0001 { False -> case reserved >. 0.0001 { True -> Error(database.DbError("这批库存已被未完成的波次占用")) False -> Error(database.DbError("库存不足")) } True -> { use target <- result.try(repo.find_location(conn, warehouse_id, input.to_location)) case target { None -> Error(database.DbError("目标库位不存在")) Some(spot) -> case spot.enabled { False -> Error(database.DbError("目标库位已停用")) True -> { use _ <- result.try(repo.deduct_inventory(conn, source, input.qty, created_at)) use _ <- result.try(repo.add_inventory( conn, warehouse_id, arrival(source, input.to_location, input.qty), created_at, )) let row = Move( id: move_id, warehouse_id: warehouse_id, from_location: source.location_code, to_location: input.to_location, sku_id: source.sku_id, goods_code: source.goods_code, batch_no: source.batch_no, produced_on: source.produced_on, expires_on: source.expires_on, qty: input.qty, created_by: created_by, created_at: created_at, ) use _ <- result.try(insert_move(conn, row)) Ok(row) } } } } } } } } } } } fn find_batch( conn: Conn, warehouse_id: Int, input: MoveInput, ) -> Result(Option(InventoryBatch), DbError) { use rows <- result.try(database.query_on( conn, "SELECT warehouse_id, location_code, sku_id, goods_code, batch_no, produced_on, expires_on, qty FROM inventory_batches WHERE warehouse_id = ? AND location_code = ? AND sku_id = ? AND batch_no = ? AND produced_on = ? AND expires_on = ?", [ IntParam(warehouse_id), TextParam(input.from_location), IntParam(input.sku_id), TextParam(input.batch_no), TextParam(input.produced_on), TextParam(input.expires_on), ], { use warehouse_id <- decode.field(0, json_codec.int()) use location_code <- decode.field(1, decode.string) use sku_id <- decode.field(2, json_codec.int()) use goods_code <- decode.field(3, decode.string) use batch_no <- decode.field(4, decode.string) use produced_on <- decode.field(5, decode.string) use expires_on <- decode.field(6, decode.string) use qty <- decode.field(7, json_codec.number()) decode.success(InventoryBatch( warehouse_id: warehouse_id, location_code: location_code, sku_id: sku_id, goods_code: goods_code, batch_no: batch_no, produced_on: produced_on, expires_on: expires_on, qty: qty, )) }, )) case rows { [] -> Ok(None) [batch, ..] -> Ok(Some(batch)) } } fn reserved_qty(conn: Conn, batch: InventoryBatch) -> Result(Float, DbError) { use rows <- result.try(database.query_on( conn, "SELECT COALESCE(SUM(p.qty), 0) FROM wave_picks p JOIN waves w ON w.id = p.wave_id WHERE w.warehouse_id = ? AND w.status = 'open' AND p.location_code = ? AND p.sku_id = ? AND p.goods_code = ? AND p.batch_no = ? AND p.produced_on = ? AND p.expires_on = ?", [ IntParam(batch.warehouse_id), TextParam(batch.location_code), IntParam(batch.sku_id), TextParam(batch.goods_code), TextParam(batch.batch_no), TextParam(batch.produced_on), TextParam(batch.expires_on), ], { use qty <- decode.field(0, json_codec.number()) decode.success(qty) }, )) case rows { [qty, ..] -> Ok(qty) [] -> Ok(0.0) } } fn arrival(source: InventoryBatch, location_code: String, qty: Float) -> ReceiptLine { ReceiptLine( line_no: 1, bill_detail_id: 0, sku_id: source.sku_id, goods_code: source.goods_code, goods_name: "", barcode: "", received_qty: qty, batch_no: source.batch_no, produced_on: source.produced_on, expires_on: source.expires_on, location_code: location_code, ) } fn insert_move(conn: Conn, row: Move) -> Result(Nil, DbError) { database.exec_on( conn, "INSERT INTO stock_moves ( id, warehouse_id, from_location, to_location, sku_id, goods_code, batch_no, produced_on, expires_on, qty, created_by, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ TextParam(row.id), IntParam(row.warehouse_id), TextParam(row.from_location), TextParam(row.to_location), IntParam(row.sku_id), TextParam(row.goods_code), TextParam(row.batch_no), TextParam(row.produced_on), TextParam(row.expires_on), FloatParam(row.qty), TextParam(row.created_by), TextParam(row.created_at), ], ) } fn list_rows( conn: Conn, warehouse_id: Int, limit: Int, offset: Int, ) -> Result(#(Int, List(Move)), DbError) { use totals <- result.try(database.query_on( conn, "SELECT COUNT(*) FROM stock_moves WHERE warehouse_id = ?", [IntParam(warehouse_id)], { use total <- decode.field(0, json_codec.int()) decode.success(total) }, )) let total = case totals { [value, ..] -> value [] -> 0 } use rows <- result.try(database.query_on( conn, "SELECT id, warehouse_id, from_location, to_location, sku_id, goods_code, batch_no, produced_on, expires_on, qty, created_by, created_at FROM stock_moves WHERE warehouse_id = ? ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", [IntParam(warehouse_id), IntParam(limit), IntParam(offset)], { use id <- decode.field(0, decode.string) use warehouse_id <- decode.field(1, json_codec.int()) use from_location <- decode.field(2, decode.string) use to_location <- decode.field(3, decode.string) use sku_id <- decode.field(4, json_codec.int()) use goods_code <- decode.field(5, decode.string) use batch_no <- decode.field(6, decode.string) use produced_on <- decode.field(7, decode.string) use expires_on <- decode.field(8, decode.string) use qty <- decode.field(9, json_codec.number()) use created_by <- decode.field(10, decode.string) use created_at <- decode.field(11, decode.string) decode.success(Move( id: id, warehouse_id: warehouse_id, from_location: from_location, to_location: to_location, sku_id: sku_id, goods_code: goods_code, batch_no: batch_no, produced_on: produced_on, expires_on: expires_on, qty: qty, created_by: created_by, created_at: created_at, )) }, )) Ok(#(total, rows)) } fn normalize_code(value: String) -> String { string.uppercase(string.trim(value)) } fn classify(message: String) -> MoveError { case message == "仓库不存在" || message == "只能在作业仓库移库" || message == "源库位没有这个批次" || message == "这批库存已被未完成的波次占用" || message == "库存不足" || message == "目标库位不存在" || message == "目标库位已停用" { True -> MoveRejected(message) False -> MoveDb(database.DbError(message)) } } fn compact(value: String) -> String { value |> string.replace("-", "") |> string.replace(" ", "") |> string.replace(":", "") }