Artifact 77e630b26efdd5d483d382d5b3145ebf4a405ff0b63c969c495476d4f6ebab7d:
- File
backend/src/receiving.gleam
— part of check-in
[32512e470a]
at
2026-10-23 04:50:54
on branch trunk
— 总仓采购订单可以在本地收货并记入库位批次库存。
收货校验实收数量、批次和保质期,库位必须已启用。不回写来肯,也不生成到货单。 (user: pluto size: 8252)
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 PurchaseOrderItem} import repo.{type Receipt, type ReceiptLine, Receipt, ReceiptLine} import wisp pub type LineInput { LineInput( bill_detail_id: Int, received_qty: Float, batch_no: String, produced_on: String, expires_on: String, location_code: String, ) } pub type ReceiptError { ReceiptRejected(String) ReceiptDb(DbError) } pub fn normalize_line(input: LineInput) -> Result(LineInput, String) { use received_qty <- result.try(parse_qty(input.received_qty)) use batch_no <- result.try(parse_batch(input.batch_no)) use produced_on <- result.try(parse_day(input.produced_on)) use expires_on <- result.try(parse_day(input.expires_on)) let location_code = string.uppercase(string.trim(input.location_code)) case location_code == "" { True -> Error("库位不能为空") False -> case string.compare(produced_on, expires_on) { order.Gt -> Error("保质期到期日不能早于生产日期") _ -> Ok(LineInput( ..input, received_qty: received_qty, batch_no: batch_no, produced_on: produced_on, expires_on: expires_on, location_code: location_code, )) } } } pub fn parse_qty(value: Float) -> Result(Float, String) { case value >. 0.0 { True -> Ok(value) False -> Error("实收数量必须大于 0") } } pub fn parse_batch(value: String) -> Result(String, String) { let value = string.trim(value) case value == "" || string.length(value) > 40 { True -> Error("批次号不能为空,且不超过 40 个字符") False -> Ok(value) } } pub fn parse_day(value: String) -> Result(String, String) { let value = string.trim(value) case string.length(value) == 10 && string.slice(value, 4, 1) == "-" && string.slice(value, 7, 1) == "-" { False -> Error("日期格式应为 YYYY-MM-DD") True -> case int.parse(string.slice(value, 0, 4)), int.parse(string.slice(value, 5, 2)), int.parse(string.slice(value, 8, 2)) { Ok(year), Ok(month), Ok(day) if year >= 2000 && month >= 1 && month <= 12 && day >= 1 && day <= 31 -> Ok(value) _, _, _ -> Error("日期格式应为 YYYY-MM-DD") } } } pub fn create( db: Database, bill_id: Int, inputs: List(LineInput), ) -> Result(Receipt, ReceiptError) { case inputs { [] -> Error(ReceiptRejected("至少要有一行收货明细")) _ -> { let created_at = clock.now_local() let receipt_id = compact(created_at) <> "-" <> wisp.random_string(8) case database.transaction(db, fn(conn) { post(conn, bill_id, receipt_id, created_at, inputs) }) { Ok(receipt) -> Ok(receipt) Error(database.DbError(message)) -> Error(classify(message)) } } } } fn post( conn: Conn, bill_id: Int, receipt_id: String, created_at: String, inputs: List(LineInput), ) -> Result(Receipt, DbError) { use order <- result.try(repo.get_inbound(conn, bill_id)) case order { 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(), [], )) let receipt = Receipt( id: receipt_id, bill_id: bill_id, warehouse_id: order.warehouse_id, created_at: created_at, lines: list.reverse(lines), ) use _ <- result.try(repo.insert_receipt(conn, receipt)) use _ <- result.try(list.try_fold(receipt.lines, Nil, fn(_, line) { repo.add_inventory(conn, receipt.warehouse_id, line, created_at) })) Ok(receipt) } } } } } } } fn prepare_lines( conn: Conn, bill_id: Int, warehouse_id: Int, items: List(PurchaseOrderItem), inputs: List(LineInput), seen: Dict(Int, Nil), acc: List(ReceiptLine), ) -> Result(List(ReceiptLine), DbError) { case inputs { [] -> Ok(acc) [input, ..rest] -> case normalize_line(input) { Error(message) -> Error(database.DbError(message)) Ok(input) -> 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 spot <- result.try(repo.find_location(conn, warehouse_id, input.location_code)) case spot { Some(spot) if spot.enabled -> { use already <- result.try(repo.sum_received_qty(conn, bill_id, input.bill_detail_id)) case already +. input.received_qty <=. item.base_qty +. 0.0001 { False -> Error(database.DbError("实收数量超过订单未收数量")) True -> { let line = ReceiptLine( line_no: list.length(acc) + 1, bill_detail_id: input.bill_detail_id, sku_id: item.sku_id, goods_code: item.goods_code, goods_name: item.goods_name, barcode: item.barcode, received_qty: input.received_qty, batch_no: input.batch_no, produced_on: input.produced_on, expires_on: input.expires_on, location_code: spot.code, ) prepare_lines( conn, bill_id, warehouse_id, items, rest, dict.insert(seen, input.bill_detail_id, Nil), [line, ..acc], ) } } } _ -> Error(database.DbError("库位不存在或未启用")) } } } } } } } fn classify(message: String) -> ReceiptError { case message == "采购订单不存在" || message == "只能对总仓等待收货的采购订单收货" || message == "采购订单明细尚未同步" || string.starts_with(message, "实收") || string.starts_with(message, "库位") || string.starts_with(message, "批次") || string.starts_with(message, "日期") || string.starts_with(message, "保质") || string.starts_with(message, "明细") || string.starts_with(message, "同一") { True -> ReceiptRejected(message) False -> ReceiptDb(database.DbError(message)) } } fn compact(value: String) -> String { value |> string.replace("-", "") |> string.replace(" ", "") |> string.replace(":", "") }