Artifact f0adb998863948620a58b43c17ce8780743f7c920760d134a5cf45195e725aee:
- File
backend/src/putaway.gleam
— part of check-in
[5ec5d40378]
at
2026-10-23 06:16:24
on branch trunk
— 上架推荐,并在 Web 控制台确认上架
- 同一批次优先集中;否则同品同到期日集中;新批次按到期先后放到相邻空库位 - 上架只在当前作业仓库进行,日期沿用该批次码已有的生产日期和到期日 - 采购入库单的批次码可进入上架页,没有库位时不推荐、也不能提交 (user: pluto size: 15920)
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(":", "") }