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 51a0c08343026d50b2cd68256ed3e67660a32edd6c5ec396c38d02ecfeb9f888:


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(":", "")
}

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.