Artifact 29be83076a841e88887ef26c35f503e080d5ecd59bde622c9fdb46f07f35e298:
- File
backend/src/wave.gleam
— part of check-in
[d49666a1ca]
at
2026-10-23 06:33:00
on branch trunk
— 波次组单,按到期日占用库存并排出拣货路线
- 制单员把同一作业仓的待发货订单合成波次,相同商品汇总后优先占用更早到期的批次 - 拣货行按库位编码排列,缺货单独标出;组单和撤销都不扣库存 - 未完成波次会占住推荐数量,避免下一张波次重复分配 (user: pluto size: 27337)
import clock import database.{ type Conn, type Database, type DbError, FloatParam, IntParam, TextParam, } import gleam/dict import gleam/dynamic/decode import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/order import gleam/result import gleam/string import ql361api/json_codec import ql361api/models.{type SalesOrder, type SalesOrderItem} import repo.{type InventoryBatch, InventoryBatch} import shipping import wisp pub type WaveError { WaveRejected(String) WaveDb(DbError) } pub type Demand { Demand( bill_id: Int, bill_code: String, bill_detail_id: Int, sku_id: Int, goods_code: String, goods_name: String, barcode: String, spec: String, base_unit: String, qty: Float, ) } pub type Allocation { Allocation( bill_id: Int, bill_code: String, bill_detail_id: Int, qty: Float, ) } pub type PickLine { PickLine( line_no: Int, sku_id: Int, goods_code: String, goods_name: String, barcode: String, spec: String, base_unit: String, location_code: String, batch_no: String, produced_on: String, expires_on: String, qty: Float, short_qty: Float, allocations: List(Allocation), ) } pub type WaveOrder { WaveOrder(bill_id: Int, bill_code: String, customer_name: String) } pub type WaveSummary { WaveSummary( id: String, warehouse_id: Int, warehouse_name: String, status: String, order_count: Int, pick_lines: Int, short_qty: Float, created_by: String, created_at: String, ) } pub type Wave { Wave( id: String, warehouse_id: Int, warehouse_name: String, status: String, created_by: String, created_at: String, orders: List(WaveOrder), picks: List(PickLine), ) } type Header { Header( id: String, warehouse_id: Int, status: String, created_by: String, created_at: String, ) } /// 先按到期日从早到晚占用库存,再按库位编码排成一条拣货路线。缺货单独成行,不占用库位。 pub fn plan( demands: List(Demand), batches: List(InventoryBatch), ) -> List(PickLine) { group_demands(demands) |> list.flat_map(fn(group) { allocate_group(group, batches) }) |> list.sort(by_stop) |> number(1, []) } pub fn create( db: Database, bill_ids: List(Int), created_by: String, warehouse_id: Int, ) -> Result(Wave, WaveError) { case bill_ids { [] -> Error(WaveRejected("至少选择一张订单")) ids -> case list.length(ids) > 50 { True -> Error(WaveRejected("一次最多组成 50 张订单")) False -> case list.length(ids) != list.length(list.unique(ids)) { True -> Error(WaveRejected("订单重复")) False -> { let created_at = clock.now_local() let wave_id = "WV-" <> compact(created_at) <> "-" <> wisp.random_string(4) case database.transaction(db, fn(conn) { build(conn, wave_id, ids, created_by, created_at, warehouse_id) }) { Ok(wave) -> Ok(wave) Error(database.DbError(message)) -> Error(classify(message)) } } } } } } pub fn cancel( db: Database, wave_id: String, warehouse_id: Int, ) -> Result(Wave, WaveError) { let cancelled_at = clock.now_local() case database.transaction(db, fn(conn) { use found <- result.try(read_header(conn, wave_id)) case found { None -> Error(database.DbError("波次不存在")) Some(header) -> case header.warehouse_id == warehouse_id { False -> Error(database.DbError("单据不属于当前作业仓库")) True -> case header.status == "open" { False -> Error(database.DbError("只能撤销未完成的波次")) True -> { use _ <- result.try(database.exec_on( conn, "UPDATE waves SET status = 'cancelled', cancelled_at = ? WHERE id = ? AND status = 'open'", [TextParam(cancelled_at), TextParam(wave_id)], )) read_wave(conn, wave_id) } } } } }) { Ok(wave) -> Ok(wave) Error(database.DbError(message)) -> Error(classify(message)) } } pub fn get(db: Database, wave_id: String) -> Result(Wave, WaveError) { case database.transaction(db, fn(conn) { read_wave(conn, wave_id) }) { Ok(wave) -> Ok(wave) Error(database.DbError(message)) -> Error(classify(message)) } } pub fn list( db: Database, warehouse_id: Int, limit: Int, offset: Int, ) -> Result(#(Int, List(WaveSummary)), WaveError) { 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 build( conn: Conn, wave_id: String, bill_ids: List(Int), created_by: String, created_at: String, station: Int, ) -> Result(Wave, DbError) { use loaded <- result.try(load_orders(conn, bill_ids, [])) let orders = list.sort(loaded, fn(left: SalesOrder, right: SalesOrder) { case string.compare(left.bill_date, right.bill_date) { order.Eq -> int.compare(left.bill_id, right.bill_id) compared -> compared } }) use warehouse_id <- result.try(same_station(orders, station)) 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 demands <- result.try(collect_demands(conn, orders, [])) use stock <- result.try(repo.list_inventory(conn, warehouse_id)) use reserved <- result.try(list_reserved(conn, warehouse_id)) let picks = plan(demands, apply_reserved(stock, reserved)) let wave_orders = list.map(orders, fn(order: SalesOrder) { WaveOrder( bill_id: order.bill_id, bill_code: order.bill_code, customer_name: order.customer_name, ) }) let wave = Wave( id: wave_id, warehouse_id: warehouse_id, warehouse_name: stored.warehouse.fullname, status: "open", created_by: created_by, created_at: created_at, orders: wave_orders, picks: picks, ) use _ <- result.try(insert_wave(conn, wave)) Ok(wave) } } } } fn load_orders( conn: Conn, ids: List(Int), acc: List(SalesOrder), ) -> Result(List(SalesOrder), DbError) { case ids { [] -> Ok(acc) [id, ..rest] -> { use found <- result.try(repo.get_order(conn, id)) case found { None -> Error(database.DbError("销售订单不存在")) Some(order) -> load_orders(conn, rest, [order, ..acc]) } } } } fn same_station(orders: List(SalesOrder), station: Int) -> Result(Int, DbError) { case orders { [] -> Error(database.DbError("至少选择一张订单")) [first, ..] -> case list.all(orders, fn(order: SalesOrder) { order.warehouse_id == first.warehouse_id }) { False -> Error(database.DbError("订单不属于同一仓库")) True -> case first.warehouse_id == station { True -> Ok(first.warehouse_id) False -> Error(database.DbError("单据不属于当前作业仓库")) } } } } fn collect_demands( conn: Conn, orders: List(SalesOrder), acc: List(Demand), ) -> Result(List(Demand), DbError) { case orders { [] -> Ok(acc) [order, ..rest] -> { use rows <- result.try(demands_of(conn, order)) collect_demands(conn, rest, list.append(acc, rows)) } } } fn demands_of(conn: Conn, order: SalesOrder) -> Result(List(Demand), DbError) { use locked <- result.try(order_locked(conn, order.bill_id)) case locked { True -> Error(database.DbError("订单已在未完成的波次里")) False -> case order.status == 20 { False -> Error(database.DbError("只能把总仓待发货的订单组成波次")) True -> case order.items { [] -> Error(database.DbError("订单明细尚未同步")) items -> { use rows <- result.try(remaining_lines(conn, order, items, [])) case rows { [] -> Error(database.DbError("订单已发完")) _ -> Ok(list.reverse(rows)) } } } } } } fn remaining_lines( conn: Conn, order: SalesOrder, items: List(SalesOrderItem), acc: List(Demand), ) -> Result(List(Demand), DbError) { case items { [] -> Ok(acc) [item, ..rest] -> { use shipped <- result.try(repo.sum_shipped_qty(conn, order.bill_id, item.bill_detail_id)) let left = item.base_qty -. shipped let acc = case left >. 0.0001 { False -> acc True -> [ Demand( bill_id: order.bill_id, bill_code: order.bill_code, bill_detail_id: item.bill_detail_id, sku_id: item.sku_id, goods_code: item.goods_code, goods_name: item.goods_name, barcode: item.barcode, spec: item.spec, base_unit: item.base_unit, qty: left, ), ..acc ] } remaining_lines(conn, order, rest, acc) } } } fn order_locked(conn: Conn, bill_id: Int) -> Result(Bool, DbError) { use rows <- result.try(database.query_on( conn, "SELECT COUNT(*) FROM wave_orders wo JOIN waves w ON w.id = wo.wave_id WHERE wo.bill_id = ? AND w.status = 'open'", [IntParam(bill_id)], { use count <- decode.field(0, json_codec.int()) decode.success(count) }, )) case rows { [count, ..] -> Ok(count > 0) [] -> Ok(False) } } fn list_reserved(conn: Conn, warehouse_id: Int) -> Result(List(InventoryBatch), DbError) { database.query_on( conn, "SELECT location_code, sku_id, goods_code, batch_no, produced_on, expires_on, SUM(qty) FROM wave_picks p JOIN waves w ON w.id = p.wave_id WHERE w.warehouse_id = ? AND w.status = 'open' AND p.qty > 0 GROUP BY location_code, sku_id, goods_code, batch_no, produced_on, expires_on", [IntParam(warehouse_id)], { use location_code <- decode.field(0, decode.string) use sku_id <- decode.field(1, json_codec.int()) use goods_code <- decode.field(2, decode.string) use batch_no <- decode.field(3, decode.string) use produced_on <- decode.field(4, decode.string) use expires_on <- decode.field(5, decode.string) use qty <- decode.field(6, 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, )) }, ) } fn apply_reserved( batches: List(InventoryBatch), reserved: List(InventoryBatch), ) -> List(InventoryBatch) { list.filter_map(batches, fn(batch: InventoryBatch) { let held = list.fold(reserved, 0.0, fn(acc, row: InventoryBatch) { case same_slot(batch, row) { True -> acc +. row.qty False -> acc } }) let qty = batch.qty -. held case qty >. 0.0001 { True -> Ok(InventoryBatch(..batch, qty: qty)) False -> Error(Nil) } }) } fn same_slot(left: InventoryBatch, right: InventoryBatch) -> Bool { left.location_code == right.location_code && left.sku_id == right.sku_id && left.goods_code == right.goods_code && left.batch_no == right.batch_no && left.produced_on == right.produced_on && left.expires_on == right.expires_on } fn group_demands(demands: List(Demand)) -> List(List(Demand)) { let #(keys, groups) = list.fold(demands, #([], dict.new()), fn(acc, demand: Demand) { let #(keys, groups) = acc let key = stock_key(demand) case dict.get(groups, key) { Ok(rows) -> #(keys, dict.insert(groups, key, list.append(rows, [demand]))) Error(_) -> #(list.append(keys, [key]), dict.insert(groups, key, [demand])) } }) list.filter_map(keys, fn(key) { dict.get(groups, key) }) } fn allocate_group( demands: List(Demand), batches: List(InventoryBatch), ) -> List(PickLine) { case demands { [] -> [] [first, ..] -> { let matched = list.filter(batches, fn(batch) { same_goods(batch, first) }) let need = list.fold(demands, 0.0, fn(acc, demand: Demand) { acc +. demand.qty }) let available = list.fold(matched, 0.0, fn(acc, batch: InventoryBatch) { acc +. batch.qty }) let take = case available <. need { True -> available False -> need } let picks = case take >. 0.0001 { False -> [] True -> case shipping.allocate(take, matched) { Ok(#(picks, _)) -> picks Error(_) -> [] } } let picked = list.fold(picks, 0.0, fn(acc, pick: shipping.Pick) { acc +. pick.qty }) let short = need -. picked let #(lines, left) = list.fold(picks, #([], demands), fn(acc, pick: shipping.Pick) { let #(lines, pending) = acc let #(allocs, pending) = take_alloc(pick.qty, pending, []) #([from_pick(first, pick, allocs), ..lines], pending) }) let short_lines = case short >. 0.0001 { False -> [] True -> [from_short(first, left, short)] } list.append(list.reverse(lines), short_lines) } } } fn from_pick( sample: Demand, pick: shipping.Pick, allocations: List(Allocation), ) -> PickLine { PickLine( line_no: 0, sku_id: sample.sku_id, goods_code: sample.goods_code, goods_name: sample.goods_name, barcode: sample.barcode, spec: sample.spec, base_unit: sample.base_unit, location_code: pick.location_code, batch_no: pick.batch_no, produced_on: pick.produced_on, expires_on: pick.expires_on, qty: pick.qty, short_qty: 0.0, allocations: allocations, ) } fn from_short(sample: Demand, pending: List(Demand), short: Float) -> PickLine { let #(allocations, _) = take_alloc(short, pending, []) PickLine( line_no: 0, sku_id: sample.sku_id, goods_code: sample.goods_code, goods_name: sample.goods_name, barcode: sample.barcode, spec: sample.spec, base_unit: sample.base_unit, location_code: "", batch_no: "", produced_on: "", expires_on: "", qty: 0.0, short_qty: short, allocations: allocations, ) } fn take_alloc( need: Float, demands: List(Demand), acc: List(Allocation), ) -> #(List(Allocation), List(Demand)) { case need <=. 0.0001 { True -> #(list.reverse(acc), demands) False -> case demands { [] -> #(list.reverse(acc), []) [demand, ..rest] -> case demand.qty <=. 0.0001 { True -> take_alloc(need, rest, acc) False -> { let taken = case demand.qty <=. need { True -> demand.qty False -> need } let allocation = Allocation( bill_id: demand.bill_id, bill_code: demand.bill_code, bill_detail_id: demand.bill_detail_id, qty: taken, ) take_alloc( need -. taken, [Demand(..demand, qty: demand.qty -. taken), ..rest], [allocation, ..acc], ) } } } } } fn same_goods(batch: InventoryBatch, demand: Demand) -> Bool { case demand.sku_id == 0 { True -> batch.goods_code == demand.goods_code False -> batch.sku_id == demand.sku_id } } fn stock_key(demand: Demand) -> String { case demand.sku_id == 0 { True -> "code:" <> demand.goods_code False -> "sku:" <> int.to_string(demand.sku_id) } } fn by_stop(left: PickLine, right: PickLine) -> order.Order { case left.location_code == "", right.location_code == "" { True, False -> order.Gt False, True -> order.Lt _, _ -> case string.compare(left.location_code, right.location_code) { order.Eq -> case string.compare(left.expires_on, right.expires_on) { order.Eq -> case string.compare(left.batch_no, right.batch_no) { order.Eq -> string.compare(left.goods_code, right.goods_code) compared -> compared } compared -> compared } compared -> compared } } } fn number(lines: List(PickLine), n: Int, acc: List(PickLine)) -> List(PickLine) { case lines { [] -> list.reverse(acc) [line, ..rest] -> number(rest, n + 1, [PickLine(..line, line_no: n), ..acc]) } } fn insert_wave(conn: Conn, wave: Wave) -> Result(Nil, DbError) { use _ <- result.try(database.exec_on( conn, "INSERT INTO waves (id, warehouse_id, status, created_by, created_at, cancelled_at) VALUES (?, ?, ?, ?, ?, '')", [ TextParam(wave.id), IntParam(wave.warehouse_id), TextParam(wave.status), TextParam(wave.created_by), TextParam(wave.created_at), ], )) use _ <- result.try(list.try_fold(wave.orders, Nil, fn(_, order: WaveOrder) { database.exec_on( conn, "INSERT INTO wave_orders (wave_id, bill_id, bill_code, customer_name) VALUES (?, ?, ?, ?)", [ TextParam(wave.id), IntParam(order.bill_id), TextParam(order.bill_code), TextParam(order.customer_name), ], ) })) list.try_fold(wave.picks, Nil, fn(_, line: PickLine) { use _ <- result.try(database.exec_on( conn, "INSERT INTO wave_picks ( wave_id, line_no, sku_id, goods_code, goods_name, barcode, spec, base_unit, location_code, batch_no, produced_on, expires_on, qty, short_qty ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ TextParam(wave.id), IntParam(line.line_no), IntParam(line.sku_id), TextParam(line.goods_code), TextParam(line.goods_name), TextParam(line.barcode), TextParam(line.spec), TextParam(line.base_unit), TextParam(line.location_code), TextParam(line.batch_no), TextParam(line.produced_on), TextParam(line.expires_on), FloatParam(line.qty), FloatParam(line.short_qty), ], )) list.try_fold(line.allocations, Nil, fn(_, allocation: Allocation) { database.exec_on( conn, "INSERT INTO wave_allocations (wave_id, line_no, bill_id, bill_code, bill_detail_id, qty) VALUES (?, ?, ?, ?, ?, ?)", [ TextParam(wave.id), IntParam(line.line_no), IntParam(allocation.bill_id), TextParam(allocation.bill_code), IntParam(allocation.bill_detail_id), FloatParam(allocation.qty), ], ) }) }) } fn read_wave(conn: Conn, wave_id: String) -> Result(Wave, DbError) { use found <- result.try(read_header(conn, wave_id)) case found { None -> Error(database.DbError("波次不存在")) Some(header) -> { use stored <- result.try(repo.get_warehouse(conn, header.warehouse_id)) let warehouse_name = case stored { Some(row) -> row.warehouse.fullname None -> "" } use orders <- result.try(database.query_on( conn, "SELECT bill_id, bill_code, customer_name FROM wave_orders WHERE wave_id = ? ORDER BY bill_id", [TextParam(wave_id)], { use bill_id <- decode.field(0, json_codec.int()) use bill_code <- decode.field(1, decode.string) use customer_name <- decode.field(2, decode.string) decode.success(WaveOrder( bill_id: bill_id, bill_code: bill_code, customer_name: customer_name, )) }, )) use picks <- result.try(database.query_on( conn, "SELECT line_no, sku_id, goods_code, goods_name, barcode, spec, base_unit, location_code, batch_no, produced_on, expires_on, qty, short_qty FROM wave_picks WHERE wave_id = ? ORDER BY line_no", [TextParam(wave_id)], { use line_no <- decode.field(0, json_codec.int()) use sku_id <- decode.field(1, json_codec.int()) use goods_code <- decode.field(2, decode.string) use goods_name <- decode.field(3, decode.string) use barcode <- decode.field(4, decode.string) use spec <- decode.field(5, decode.string) use base_unit <- decode.field(6, decode.string) use location_code <- decode.field(7, decode.string) use batch_no <- decode.field(8, decode.string) use produced_on <- decode.field(9, decode.string) use expires_on <- decode.field(10, decode.string) use qty <- decode.field(11, json_codec.number()) use short_qty <- decode.field(12, json_codec.number()) decode.success(PickLine( line_no: line_no, sku_id: sku_id, goods_code: goods_code, goods_name: goods_name, barcode: barcode, spec: spec, base_unit: base_unit, location_code: location_code, batch_no: batch_no, produced_on: produced_on, expires_on: expires_on, qty: qty, short_qty: short_qty, allocations: [], )) }, )) use allocations <- result.try(database.query_on( conn, "SELECT line_no, bill_id, bill_code, bill_detail_id, qty FROM wave_allocations WHERE wave_id = ? ORDER BY line_no, bill_id, bill_detail_id", [TextParam(wave_id)], { use line_no <- decode.field(0, json_codec.int()) use bill_id <- decode.field(1, json_codec.int()) use bill_code <- decode.field(2, decode.string) use bill_detail_id <- decode.field(3, json_codec.int()) use qty <- decode.field(4, json_codec.number()) decode.success(#( line_no, Allocation( bill_id: bill_id, bill_code: bill_code, bill_detail_id: bill_detail_id, qty: qty, ), )) }, )) let picks = list.map(picks, fn(line: PickLine) { let rows = list.filter_map(allocations, fn(row) { let #(line_no, allocation) = row case line_no == line.line_no { True -> Ok(allocation) False -> Error(Nil) } }) PickLine(..line, allocations: rows) }) Ok(Wave( id: header.id, warehouse_id: header.warehouse_id, warehouse_name: warehouse_name, status: header.status, created_by: header.created_by, created_at: header.created_at, orders: orders, picks: picks, )) } } } fn read_header(conn: Conn, wave_id: String) -> Result(Option(Header), DbError) { use rows <- result.try(database.query_on( conn, "SELECT id, warehouse_id, status, created_by, created_at FROM waves WHERE id = ?", [TextParam(wave_id)], { use id <- decode.field(0, decode.string) use warehouse_id <- decode.field(1, json_codec.int()) use status <- decode.field(2, decode.string) use created_by <- decode.field(3, decode.string) use created_at <- decode.field(4, decode.string) decode.success(Header( id: id, warehouse_id: warehouse_id, status: status, created_by: created_by, created_at: created_at, )) }, )) case rows { [] -> Ok(None) [header, ..] -> Ok(Some(header)) } } fn list_rows( conn: Conn, warehouse_id: Int, limit: Int, offset: Int, ) -> Result(#(Int, List(WaveSummary)), DbError) { use totals <- result.try(database.query_on( conn, "SELECT COUNT(*) FROM waves 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 w.id, w.warehouse_id, COALESCE(wh.name, ''), w.status, (SELECT COUNT(*) FROM wave_orders o WHERE o.wave_id = w.id), (SELECT COUNT(*) FROM wave_picks p WHERE p.wave_id = w.id AND p.qty > 0.0001), (SELECT COALESCE(SUM(p.short_qty), 0) FROM wave_picks p WHERE p.wave_id = w.id), w.created_by, w.created_at FROM waves w LEFT JOIN warehouses wh ON wh.ql_id = w.warehouse_id WHERE w.warehouse_id = ? ORDER BY CASE WHEN w.status = 'open' THEN 0 ELSE 1 END, w.created_at DESC, w.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 warehouse_name <- decode.field(2, decode.string) use status <- decode.field(3, decode.string) use order_count <- decode.field(4, json_codec.int()) use pick_lines <- decode.field(5, json_codec.int()) use short_qty <- decode.field(6, json_codec.number()) use created_by <- decode.field(7, decode.string) use created_at <- decode.field(8, decode.string) decode.success(WaveSummary( id: id, warehouse_id: warehouse_id, warehouse_name: warehouse_name, status: status, order_count: order_count, pick_lines: pick_lines, short_qty: short_qty, created_by: created_by, created_at: created_at, )) }, )) Ok(#(total, rows)) } fn classify(message: String) -> WaveError { case message == "至少选择一张订单" || message == "一次最多组成 50 张订单" || message == "订单重复" || message == "销售订单不存在" || message == "订单不属于同一仓库" || message == "单据不属于当前作业仓库" || message == "销售订单的仓库不存在" || message == "只能把总仓待发货的订单组成波次" || message == "订单明细尚未同步" || message == "订单已发完" || message == "订单已在未完成的波次里" || message == "波次不存在" || message == "只能撤销未完成的波次" { True -> WaveRejected(message) False -> WaveDb(database.DbError(message)) } } fn compact(value: String) -> String { value |> string.replace("-", "") |> string.replace(" ", "") |> string.replace(":", "") }