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


import clock
import database.{type Conn, type DbError, type Database}
import gleam/list
import gleam/option.{None, Some}
import gleam/order
import gleam/result
import gleam/string
import receiving
import repo.{type BatchLabel, type InventoryBatch, type Putaway, Putaway, ReceiptLine}
import wisp

pub type PutawayInput {
  PutawayInput(
    batch_code: String,
    qty: Float,
    produced_on: String,
    expires_on: String,
    location_code: String,
  )
}

pub type PutawayError {
  PutawayRejected(String)
  PutawayDb(DbError)
}

pub type Suggestion {
  Suggestion(location_code: String, reason: String)
}

pub type PutawayTarget {
  PutawayTarget(
    label: BatchLabel,
    put_away_qty: Float,
    produced_on: String,
    expires_on: String,
    suggestion: Suggestion,
  )
}

pub fn create(db: Database, input: PutawayInput) -> Result(Putaway, PutawayError) {
  case normalize(input) {
    Error(message) -> Error(PutawayRejected(message))
    Ok(input) -> {
      let created_at = clock.now_local()
      let putaway_id = compact(created_at) <> "-" <> wisp.random_string(8)
      case
        database.transaction(db, fn(conn) { post(conn, putaway_id, created_at, input) })
      {
        Ok(row) -> Ok(row)
        Error(database.DbError(message)) -> Error(classify(message))
      }
    }
  }
}

pub fn lookup(
  db: Database,
  batch_code: String,
) -> Result(#(BatchLabel, Float, String, String), PutawayError) {
  let batch_code = string.uppercase(string.trim(batch_code))
  case batch_code == "" {
    True -> Error(PutawayRejected("批次码不能为空"))
    False ->
      case
        database.transaction(db, fn(conn) {
          use found <- result.try(repo.find_batch_label(conn, batch_code))
          case found {
            None -> Error(database.DbError("批次码不存在"))
            Some(label) -> {
              use put_away <- result.try(repo.sum_putaway_qty(conn, batch_code))
              use dates <- result.try(repo.putaway_dates(conn, batch_code))
              let #(produced_on, expires_on) = case dates {
                Some(pair) -> pair
                None -> #("", "")
              }
              Ok(#(label, put_away, produced_on, expires_on))
            }
          }
        })
      {
        Ok(row) -> Ok(row)
        Error(database.DbError(message)) -> Error(classify(message))
      }
  }
}

/// 推荐上架库位。库位编码需已按从小到大排好,库存只含仍有数量的批次。
/// 同一批次码集中存放;否则同品且到期日相同的集中存放;
/// 新到期日放到已有同品的前或后一个空库位,让先到期的货在更小的库位上。
pub fn recommend(
  locations: List(String),
  stocks: List(InventoryBatch),
  goods_code: String,
  sku_id: Int,
  batch_code: String,
  expires_on: String,
) -> Suggestion {
  let stocks =
    list.filter(stocks, fn(stock) {
      stock.qty >. 0.0001 && list.contains(locations, stock.location_code)
    })
  case locations {
    [] -> Suggestion("", "没有可推荐的库位")
    _ -> choose(locations, stocks, goods_code, sku_id, batch_code, expires_on)
  }
}

pub fn suggest(
  db: Database,
  batch_code: String,
  expires_on: String,
) -> Result(PutawayTarget, PutawayError) {
  let batch_code = string.uppercase(string.trim(batch_code))
  case batch_code == "" {
    True -> Error(PutawayRejected("批次码不能为空"))
    False ->
      case blank_or_day(expires_on) {
        Error(message) -> Error(PutawayRejected(message))
        Ok(requested) ->
          case
            database.transaction(db, fn(conn) {
              load_target(conn, batch_code, requested)
            })
          {
            Ok(target) -> Ok(target)
            Error(database.DbError(message)) -> Error(classify(message))
          }
      }
  }
}

fn load_target(
  conn: Conn,
  batch_code: String,
  requested_expires: String,
) -> Result(PutawayTarget, DbError) {
  use found <- result.try(repo.find_batch_label(conn, batch_code))
  case found {
    None -> Error(database.DbError("批次码不存在"))
    Some(label) ->
      case label.bill_type == 501 && label.status == 20 {
        False -> Error(database.DbError("只能上架已记账的采购入库单"))
        True ->
          case label.warehouse_found && label.operational {
            False -> Error(database.DbError("该仓库只作显示,不能上架"))
            True -> {
              use put_away <- result.try(repo.sum_putaway_qty(conn, batch_code))
              use dates <- result.try(repo.putaway_dates(conn, batch_code))
              let #(produced_on, expires_on) = case dates {
                Some(pair) -> pair
                None -> #("", requested_expires)
              }
              use locations <- result.try(repo.list_locations(conn, label.warehouse_id))
              use stocks <- result.try(repo.list_inventory(conn, label.warehouse_id))
              let codes =
                locations
                |> list.filter(fn(row) { row.enabled })
                |> list.map(fn(row) { row.code })
              let suggestion =
                recommend(
                  codes,
                  stocks,
                  label.goods_code,
                  label.sku_id,
                  label.batch_code,
                  expires_on,
                )
              Ok(PutawayTarget(
                label: label,
                put_away_qty: put_away,
                produced_on: produced_on,
                expires_on: expires_on,
                suggestion: suggestion,
              ))
            }
          }
      }
  }
}

fn choose(
  locations: List(String),
  stocks: List(InventoryBatch),
  goods_code: String,
  sku_id: Int,
  batch_code: String,
  expires_on: String,
) -> Suggestion {
  let same_batch = list.filter(stocks, fn(stock) { stock.batch_no == batch_code })
  case fullest(same_batch) {
    Ok(stock) -> Suggestion(stock.location_code, "该批次已在此库位,继续集中存放")
    Error(_) -> {
      let mine = list.filter(stocks, fn(stock) { same_goods(stock, goods_code, sku_id) })
      let same_day = case expires_on == "" {
        True -> []
        False -> list.filter(mine, fn(stock) { stock.expires_on == expires_on })
      }
      case fullest(same_day) {
        Ok(stock) -> Suggestion(stock.location_code, "同品且到期日相同,集中存放")
        Error(_) -> place_new(locations, stocks, mine, expires_on)
      }
    }
  }
}

fn place_new(
  locations: List(String),
  stocks: List(InventoryBatch),
  mine: List(InventoryBatch),
  expires_on: String,
) -> Suggestion {
  let empty = empty_codes(locations, stocks)
  case mine {
    [] ->
      case empty {
        [code, ..] -> Suggestion(code, "没有同品库存,使用空库位")
        [] -> Suggestion("", "没有可推荐的库位")
      }
    _ -> {
      let anchor = earliest(mine)
      case expires_on == "" {
        True ->
          case empty {
            [code, ..] ->
              Suggestion(code, "到期日未填,先使用空库位,避免和已有同品混放")
            [] -> Suggestion(anchor.location_code, "没有空库位,暂放到已有同品的库位")
          }
        False -> aim(empty, anchor, expires_on)
      }
    }
  }
}

fn aim(
  empty: List(String),
  anchor: InventoryBatch,
  expires_on: String,
) -> Suggestion {
  let #(found, reason) = case string.compare(expires_on, anchor.expires_on) {
    order.Lt -> #(
      closest_before(empty, anchor.location_code),
      "这批更早到期,放到同品之前的空库位,方便先出",
    )
    _ -> #(
      closest_after(empty, anchor.location_code),
      "同品更早到期的库存在前,新品放到其后的空库位",
    )
  }
  case found {
    Ok(code) -> Suggestion(code, reason)
    Error(_) ->
      case empty {
        [code, ..] -> Suggestion(code, "没有紧邻空库位,改用其他空库位")
        [] -> Suggestion(anchor.location_code, "没有空库位,暂放到已有同品的库位")
      }
  }
}

fn same_goods(stock: InventoryBatch, goods_code: String, sku_id: Int) -> Bool {
  case sku_id == 0 {
    True -> stock.goods_code == goods_code
    False -> stock.sku_id == sku_id || stock.goods_code == goods_code
  }
}

fn fullest(stocks: List(InventoryBatch)) -> Result(InventoryBatch, Nil) {
  case stocks {
    [] -> Error(Nil)
    [first, ..rest] -> Ok(list.fold(rest, first, fuller))
  }
}

fn fuller(best: InventoryBatch, stock: InventoryBatch) -> InventoryBatch {
  case stock.qty >. best.qty +. 0.0001 {
    True -> stock
    False ->
      case best.qty >. stock.qty +. 0.0001 {
        True -> best
        False ->
          case string.compare(stock.location_code, best.location_code) == order.Lt {
            True -> stock
            False -> best
          }
      }
  }
}

fn earliest(stocks: List(InventoryBatch)) -> InventoryBatch {
  let assert [first, ..rest] = stocks
  list.fold(rest, first, fn(best, stock) {
    case string.compare(stock.expires_on, best.expires_on) {
      order.Lt -> stock
      order.Gt -> best
      order.Eq ->
        case string.compare(stock.location_code, best.location_code) == order.Lt {
          True -> stock
          False -> best
        }
    }
  })
}

fn empty_codes(
  locations: List(String),
  stocks: List(InventoryBatch),
) -> List(String) {
  list.filter(locations, fn(code) {
    !list.any(stocks, fn(stock) { stock.location_code == code })
  })
}

fn closest_before(empty: List(String), anchor: String) -> Result(String, Nil) {
  list.fold(empty, Error(Nil), fn(best, code) {
    case string.compare(code, anchor) == order.Lt {
      True -> Ok(code)
      False -> best
    }
  })
}

fn closest_after(empty: List(String), anchor: String) -> Result(String, Nil) {
  list.find(empty, fn(code) { string.compare(code, anchor) == order.Gt })
}

fn blank_or_day(value: String) -> Result(String, String) {
  let value = string.trim(value)
  case value == "" {
    True -> Ok("")
    False -> receiving.parse_day(value)
  }
}

fn post(
  conn: Conn,
  putaway_id: String,
  created_at: String,
  input: PutawayInput,
) -> Result(Putaway, DbError) {
  use found <- result.try(repo.find_batch_label(conn, input.batch_code))
  case found {
    None -> Error(database.DbError("批次码不存在"))
    Some(label) ->
      case label.bill_type == 501 && label.status == 20 {
        False -> Error(database.DbError("只能上架已记账的采购入库单"))
        True ->
          case label.warehouse_found && label.operational {
            False -> Error(database.DbError("该仓库只作显示,不能上架"))
            True -> {
              use spot <- result.try(repo.find_location(
                conn,
                label.warehouse_id,
                input.location_code,
              ))
              case spot {
                Some(spot) if spot.enabled -> {
                  use already <- result.try(repo.sum_putaway_qty(conn, label.batch_code))
                  case already +. input.qty <=. label.base_qty +. 0.0001 {
                    False -> Error(database.DbError("上架数量超过采购入库单未上架数量"))
                    True -> {
                      use dates <- result.try(repo.putaway_dates(conn, label.batch_code))
                      case same_dates(dates, input.produced_on, input.expires_on) {
                        False -> Error(database.DbError("同一批次码的生产日期和到期日必须一致"))
                        True -> {
                          let row =
                            Putaway(
                              id: putaway_id,
                              batch_code: label.batch_code,
                              bill_id: label.bill_id,
                              bill_detail_id: label.bill_detail_id,
                              warehouse_id: label.warehouse_id,
                              sku_id: label.sku_id,
                              goods_code: label.goods_code,
                              goods_name: label.goods_name,
                              location_code: spot.code,
                              produced_on: input.produced_on,
                              expires_on: input.expires_on,
                              qty: input.qty,
                              created_at: created_at,
                            )
                          use _ <- result.try(repo.insert_putaway(conn, row))
                          use _ <- result.try(repo.add_inventory(
                            conn,
                            label.warehouse_id,
                            ReceiptLine(
                              line_no: 1,
                              bill_detail_id: label.bill_detail_id,
                              sku_id: label.sku_id,
                              goods_code: label.goods_code,
                              goods_name: label.goods_name,
                              barcode: label.barcode,
                              received_qty: input.qty,
                              batch_no: label.batch_code,
                              produced_on: input.produced_on,
                              expires_on: input.expires_on,
                              location_code: spot.code,
                            ),
                            created_at,
                          ))
                          Ok(row)
                        }
                      }
                    }
                  }
                }
                _ -> Error(database.DbError("库位不存在或未启用"))
              }
            }
          }
      }
  }
}

fn same_dates(
  dates: option.Option(#(String, String)),
  produced_on: String,
  expires_on: String,
) -> Bool {
  case dates {
    None -> True
    Some(#(existing_produced, existing_expires)) ->
      existing_produced == produced_on && existing_expires == expires_on
  }
}

fn normalize(input: PutawayInput) -> Result(PutawayInput, String) {
  let batch_code = string.uppercase(string.trim(input.batch_code))
  let location_code = string.uppercase(string.trim(input.location_code))
  case batch_code == "" {
    True -> Error("批次码不能为空")
    False ->
      case location_code == "" {
        True -> Error("库位不能为空")
        False ->
          case input.qty >. 0.0 {
            False -> Error("上架数量必须大于 0")
            True -> {
              use produced_on <- result.try(receiving.parse_day(input.produced_on))
              use expires_on <- result.try(receiving.parse_day(input.expires_on))
              case string.compare(produced_on, expires_on) == order.Gt {
                True -> Error("保质期到期日不能早于生产日期")
                False ->
                  Ok(PutawayInput(
                    batch_code: batch_code,
                    qty: input.qty,
                    produced_on: produced_on,
                    expires_on: expires_on,
                    location_code: location_code,
                  ))
              }
            }
          }
      }
  }
}

fn classify(message: String) -> PutawayError {
  case
    message == "批次码不存在"
    || message == "批次码不能为空"
    || message == "只能上架已记账的采购入库单"
    || message == "该仓库只作显示,不能上架"
    || message == "库位不存在或未启用"
    || message == "库位不能为空"
    || message == "上架数量必须大于 0"
    || message == "上架数量超过采购入库单未上架数量"
    || message == "同一批次码的生产日期和到期日必须一致"
    || message == "保质期到期日不能早于生产日期"
    || string.starts_with(message, "日期")
  {
    True -> PutawayRejected(message)
    False -> PutawayDb(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.