Artifact f863fc28fb35629acf1c197d659d8cb869c784501ed5b20629923edabdbee062:
- File
backend/src/shipping.gleam
— part of check-in
[528be8d396]
at
2026-10-23 05:01:01
on branch trunk
— 总仓销售订单按保质期从已收批次扣库存。
先到期的先出,库存不够或超过未发数量就拒绝。不回写来肯。 (user: pluto size: 9839)
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(":", "") }