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 f863fc28fb35629acf1c197d659d8cb869c784501ed5b20629923edabdbee062:


import clock
import database.{type Conn, type DbError, type Database}
import gleam/dict.{type Dict}
import gleam/int
import gleam/list
import gleam/option.{None, Some}
import gleam/order
import gleam/result
import gleam/string
import ql361api/models.{type SalesOrderItem}
import repo.{type InventoryBatch, type Shipment, type ShipmentLine, InventoryBatch, Shipment, ShipmentLine}
import wisp

pub type ShipLine {
  ShipLine(bill_detail_id: Int, qty: Float)
}

pub type Pick {
  Pick(
    location_code: String,
    sku_id: Int,
    goods_code: String,
    batch_no: String,
    produced_on: String,
    expires_on: String,
    qty: Float,
  )
}

pub type ShipmentError {
  ShipmentRejected(String)
  ShipmentDb(DbError)
}

pub fn allocate(
  need: Float,
  batches: List(InventoryBatch),
) -> Result(#(List(Pick), List(InventoryBatch)), Nil) {
  take(need, sort_batches(batches), [])
}

pub fn create(
  db: Database,
  bill_id: Int,
  inputs: List(ShipLine),
) -> Result(Shipment, ShipmentError) {
  case inputs {
    [] -> Error(ShipmentRejected("至少要有一行出库明细"))
    _ -> {
      let created_at = clock.now_local()
      let shipment_id = compact(created_at) <> "-" <> wisp.random_string(8)
      case
        database.transaction(db, fn(conn) {
          post(conn, bill_id, shipment_id, created_at, inputs)
        })
      {
        Ok(shipment) -> Ok(shipment)
        Error(database.DbError(message)) -> Error(classify(message))
      }
    }
  }
}

fn post(
  conn: Conn,
  bill_id: Int,
  shipment_id: String,
  created_at: String,
  inputs: List(ShipLine),
) -> Result(Shipment, DbError) {
  use found <- result.try(repo.get_order(conn, bill_id))
  case found {
    None -> Error(database.DbError("销售订单不存在"))
    Some(order) -> {
          use warehouse <- result.try(repo.get_warehouse(conn, order.warehouse_id))
          case warehouse {
            None -> Error(database.DbError("销售订单的仓库不存在"))
            Some(row) ->
              case order.status == 20 && row.operational {
                False -> Error(database.DbError("只能对总仓待发货的销售订单出库"))
                True ->
                  case order.items {
                    [] -> Error(database.DbError("销售订单明细尚未同步"))
                    items -> {
                      use lines <- result.try(prepare_lines(
                        conn,
                        bill_id,
                        order.warehouse_id,
                        items,
                        inputs,
                        dict.new(),
                        dict.new(),
                        [],
                        0,
                      ))
                      let shipment =
                        Shipment(
                          id: shipment_id,
                          bill_id: bill_id,
                          warehouse_id: order.warehouse_id,
                          created_at: created_at,
                          lines: list.reverse(lines),
                        )
                      use _ <- result.try(list.try_fold(shipment.lines, Nil, fn(_, line) {
                        repo.deduct_inventory(
                          conn,
                          InventoryBatch(
                            warehouse_id: shipment.warehouse_id,
                            location_code: line.location_code,
                            sku_id: line.sku_id,
                            goods_code: line.goods_code,
                            batch_no: line.batch_no,
                            produced_on: line.produced_on,
                            expires_on: line.expires_on,
                            qty: line.qty,
                          ),
                          line.qty,
                          created_at,
                        )
                      }))
                      use _ <- result.try(repo.insert_shipment(conn, shipment))
                      Ok(shipment)
                    }
                  }
              }
          }
    }
  }
}

fn prepare_lines(
  conn: Conn,
  bill_id: Int,
  warehouse_id: Int,
  items: List(SalesOrderItem),
  inputs: List(ShipLine),
  seen: Dict(Int, Nil),
  stock: Dict(String, List(InventoryBatch)),
  acc: List(ShipmentLine),
  line_no: Int,
) -> Result(List(ShipmentLine), DbError) {
  case inputs {
    [] -> Ok(acc)
    [input, ..rest] ->
      case input.qty >. 0.0 {
        False -> Error(database.DbError("出库数量必须大于 0"))
        True ->
          case dict.has_key(seen, input.bill_detail_id) {
            True -> Error(database.DbError("同一明细不能在一张出库单里重复"))
            False ->
              case list.find(items, fn(item) { item.bill_detail_id == input.bill_detail_id }) {
                Error(_) -> Error(database.DbError("明细不属于这张销售订单"))
                Ok(item) -> {
                  use already <- result.try(repo.sum_shipped_qty(conn, bill_id, input.bill_detail_id))
                  case already +. input.qty <=. item.base_qty +. 0.0001 {
                    False -> Error(database.DbError("出库数量超过订单未发数量"))
                    True -> {
                      use loaded <- result.try(load_stock(conn, warehouse_id, item, stock))
                      let #(batches, stock) = loaded
                      case allocate(input.qty, batches) {
                        Error(_) -> Error(database.DbError("库存不足"))
                        Ok(#(picks, left)) -> {
                          let #(lines, line_no) = picks_to_lines(item, picks, acc, line_no)
                          prepare_lines(
                            conn,
                            bill_id,
                            warehouse_id,
                            items,
                            rest,
                            dict.insert(seen, input.bill_detail_id, Nil),
                            dict.insert(stock, stock_key(item), left),
                            lines,
                            line_no,
                          )
                        }
                      }
                    }
                  }
                }
              }
          }
      }
  }
}

fn load_stock(
  conn: Conn,
  warehouse_id: Int,
  item: SalesOrderItem,
  stock: Dict(String, List(InventoryBatch)),
) -> Result(#(List(InventoryBatch), Dict(String, List(InventoryBatch))), DbError) {
  let key = stock_key(item)
  case dict.get(stock, key) {
    Ok(batches) -> Ok(#(batches, stock))
    Error(_) -> {
      use rows <- result.try(repo.list_batches_for_sku(
        conn,
        warehouse_id,
        item.sku_id,
        item.goods_code,
      ))
      Ok(#(rows, dict.insert(stock, key, rows)))
    }
  }
}

fn picks_to_lines(
  item: SalesOrderItem,
  picks: List(Pick),
  acc: List(ShipmentLine),
  line_no: Int,
) -> #(List(ShipmentLine), Int) {
  case picks {
    [] -> #(acc, line_no)
    [pick, ..rest] -> {
      let line_no = line_no + 1
      let line =
        ShipmentLine(
          line_no: line_no,
          bill_detail_id: item.bill_detail_id,
          sku_id: item.sku_id,
          goods_code: item.goods_code,
          goods_name: item.goods_name,
          batch_no: pick.batch_no,
          produced_on: pick.produced_on,
          expires_on: pick.expires_on,
          location_code: pick.location_code,
          qty: pick.qty,
        )
      picks_to_lines(item, rest, [line, ..acc], line_no)
    }
  }
}

fn take(
  need: Float,
  batches: List(InventoryBatch),
  picks: List(Pick),
) -> Result(#(List(Pick), List(InventoryBatch)), Nil) {
  case need <=. 0.0001 {
    True -> Ok(#(list.reverse(picks), batches))
    False ->
      case batches {
        [] -> Error(Nil)
        [batch, ..rest] ->
          case batch.qty <=. 0.0001 {
            True -> take(need, rest, picks)
            False -> {
              let taken = case batch.qty <=. need {
                True -> batch.qty
                False -> need
              }
              let pick =
                Pick(
                  location_code: batch.location_code,
                  sku_id: batch.sku_id,
                  goods_code: batch.goods_code,
                  batch_no: batch.batch_no,
                  produced_on: batch.produced_on,
                  expires_on: batch.expires_on,
                  qty: taken,
                )
              let left = InventoryBatch(..batch, qty: batch.qty -. taken)
              take(need -. taken, [left, ..rest], [pick, ..picks])
            }
          }
      }
  }
}

pub fn sort_batches(batches: List(InventoryBatch)) -> List(InventoryBatch) {
  list.sort(batches, fn(left, right) {
    case string.compare(left.expires_on, right.expires_on) {
      order.Eq ->
        case string.compare(left.location_code, right.location_code) {
          order.Eq -> string.compare(left.batch_no, right.batch_no)
          compared -> compared
        }
      compared -> compared
    }
  })
}

fn stock_key(item: SalesOrderItem) -> String {
  case item.sku_id == 0 {
    True -> "code:" <> item.goods_code
    False -> "sku:" <> int.to_string(item.sku_id)
  }
}

fn classify(message: String) -> ShipmentError {
  case
    message == "销售订单不存在"
    || message == "销售订单的仓库不存在"
    || message == "只能对总仓待发货的销售订单出库"
    || message == "销售订单明细尚未同步"
    || message == "库存不足"
    || string.starts_with(message, "出库")
    || string.starts_with(message, "明细")
    || string.starts_with(message, "同一")
  {
    True -> ShipmentRejected(message)
    False -> ShipmentDb(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.