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 3bab1ab80d0e4c62f14a984455d6ed5a33f52ac2a244b5e1bf917518ffe96aa1:


import app.{type Context}
import access.{type Principal}
import clock
import config.{Postgres, Sqlite}
import database
import gleam/dict
import gleam/dynamic/decode
import gleam/float
import gleam/http.{type Method, Get, Post, Put}
import gleam/http/request
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import location.{type Location, Location}
import location_plan
import picking
import putaway
import ql361api.{
  type GoodsItem, type PurchaseOrder, type PurchaseOrderItem,
  type PurchaseOrderSummary, type SalesOrder, type SalesOrderItem,
  type SalesOrderSummary,
}
import ql361api/json_codec
import rbac
import receiving
import repo
import shipping
import stock_move
import session
import sync_inbounds
import sync_orders
import sync_purchase_receipts
import sync_products
import sync_report
import user_repo
import wave
import wisp.{type Request, type Response}

pub fn handle_request(req: Request, ctx: Context) -> Response {
  use req <- wisp.handle_head(req)

  case wisp.path_segments(req) {
    [] -> only(req, Get, fn() { home_page(ctx) })
    ["api", "health"] -> only(req, Get, fn() { health(ctx) })
    ["api", "auth", "login"] -> only(req, Post, fn() { login(req, ctx) })
    segments ->
      case access.authenticate(ctx.db, bearer_token(req)) {
        Error(err) -> auth_error(err)
        Ok(p) -> protected(req, ctx, p, segments)
      }
  }
}

fn protected(
  req: Request,
  ctx: Context,
  p: Principal,
  segments: List(String),
) -> Response {
  case segments {
    ["api", "auth", "me"] -> only(req, Get, fn() { me(p) })
    ["api", "auth", "logout"] -> only(req, Post, fn() { logout(ctx, p) })
    ["api", "auth", "warehouse"] ->
      only(req, Post, fn() { select_warehouse(req, ctx, p) })
    ["api", "auth", "password"] ->
      only(req, Post, fn() { change_password(req, ctx, p) })
    ["api", "users"] -> {
      use <- need(p, rbac.user_manage)
      case req.method {
        Get -> list_users(ctx)
        Post -> create_user(req, ctx, p)
        _ -> json_error(405, "方法不允许")
      }
    }
    ["api", "users", user_id] -> {
      use <- need(p, rbac.user_manage)
      only(req, Put, fn() { update_user(req, ctx, p, user_id) })
    }
    ["api", "users", user_id, "password"] -> {
      use <- need(p, rbac.user_manage)
      only(req, Post, fn() { reset_password(req, ctx, p, user_id) })
    }
    ["api", "roles"] -> {
      use <- need(p, rbac.user_manage)
      only(req, Get, fn() { list_roles(ctx) })
    }
    ["api", "audit-logs"] -> {
      use <- need(p, rbac.audit_view)
      only(req, Get, fn() { list_audit(req, ctx) })
    }
    ["api", "ql361", "status"] -> {
      use <- need(p, rbac.sync_view)
      only(req, Get, fn() { ql361_status(ctx) })
    }
    ["api", "ql361", "login"] -> {
      use <- need(p, rbac.sync_run)
      only(req, Post, fn() { ql361_login(ctx) })
    }
    ["api", "sync", "status"] -> {
      use <- need(p, rbac.sync_view)
      only(req, Get, fn() { sync_status(ctx) })
    }
    ["api", "sync", "products"] -> {
      use <- need(p, rbac.sync_run)
      only(req, Post, fn() { sync_products_now(req, ctx, p) })
    }
    ["api", "sync", "orders"] -> {
      use <- need(p, rbac.sync_run)
      only(req, Post, fn() { sync_orders_now(ctx, p) })
    }
    ["api", "sync", "inbounds"] -> {
      use <- need(p, rbac.sync_run)
      only(req, Post, fn() { sync_inbounds_now(ctx, p) })
    }
    ["api", "sync", "purchase-receipts"] -> {
      use <- need(p, rbac.sync_run)
      only(req, Post, fn() { sync_purchase_receipts_now(ctx, p) })
    }
    ["api", "warehouses"] -> {
      use <- need(p, rbac.warehouse_view)
      only(req, Get, fn() { list_warehouses(ctx, p) })
    }
    ["api", "locations"] ->
      case req.method {
        Get -> {
          use <- need(p, rbac.location_view)
          list_locations(req, ctx, p)
        }
        Post -> {
          use <- need(p, rbac.location_manage)
          create_location(req, ctx, p)
        }
        _ -> json_error(405, "方法不允许")
      }
    ["api", "locations", "plan", "preview"] -> {
      use <- need(p, rbac.location_manage)
      only(req, Post, fn() { plan_locations(req, ctx, p, False) })
    }
    ["api", "locations", "plan"] -> {
      use <- need(p, rbac.location_manage)
      only(req, Post, fn() { plan_locations(req, ctx, p, True) })
    }
    ["api", "locations", "disable"] -> {
      use <- need(p, rbac.location_manage)
      only(req, Post, fn() { disable_location(req, ctx, p) })
    }
    ["api", "products"] -> {
      use <- need(p, rbac.product_view)
      only(req, Get, fn() { list_products(req, ctx) })
    }
    ["api", "orders", "pending"] -> {
      use <- need(p, rbac.order_view)
      only(req, Get, fn() { list_orders(req, ctx, p, Some(20)) })
    }
    ["api", "orders", "all"] -> {
      use <- need(p, rbac.order_view)
      only(req, Get, fn() { list_orders(req, ctx, p, None) })
    }
    ["api", "orders", "detail", bill_id] -> {
      use <- need(p, rbac.order_view)
      only(req, Get, fn() { order_detail(ctx, p, bill_id) })
    }
    ["api", "orders", bill_id, "shipments"] ->
      case req.method {
        Get -> {
          use <- need(p, rbac.order_view)
          list_shipments(ctx, p, bill_id)
        }
        Post -> {
          use <- need(p, rbac.shipment_execute)
          create_shipment(req, ctx, p, bill_id)
        }
        _ -> json_error(405, "方法不允许")
      }
    ["api", "inbounds", "pending"] -> {
      use <- need(p, rbac.inbound_view)
      only(req, Get, fn() { list_inbounds(req, ctx, p, 504, Some(20)) })
    }
    ["api", "purchase-receipts"] -> {
      use <- need(p, rbac.inbound_view)
      only(req, Get, fn() { list_inbounds(req, ctx, p, 501, Some(20)) })
    }
    ["api", "purchase-receipts", "detail", bill_id] -> {
      use <- need(p, rbac.inbound_view)
      only(req, Get, fn() { purchase_receipt_detail(ctx, p, bill_id) })
    }
    ["api", "batches", batch_code] -> {
      use <- need(p, rbac.inbound_view)
      only(req, Get, fn() { batch_lookup(ctx, p, batch_code) })
    }
    ["api", "putaways", "recommend"] -> {
      use <- need(p, rbac.putaway_execute)
      only(req, Post, fn() { recommend_putaway(req, ctx, p) })
    }
    ["api", "putaways"] -> {
      use <- need(p, rbac.putaway_execute)
      only(req, Post, fn() { create_putaway(req, ctx, p) })
    }
    ["api", "inbounds", "detail", bill_id] -> {
      use <- need(p, rbac.inbound_view)
      only(req, Get, fn() { inbound_detail(ctx, p, bill_id) })
    }
    ["api", "inbounds", bill_id, "receipts"] ->
      case req.method {
        Get -> {
          use <- need(p, rbac.inbound_view)
          list_receipts(ctx, p, bill_id)
        }
        Post -> {
          use <- need(p, rbac.receiving_execute)
          create_receipt(req, ctx, p, bill_id)
        }
        _ -> json_error(405, "方法不允许")
      }
    ["api", "inventory"] -> {
      use <- need(p, rbac.inventory_view)
      only(req, Get, fn() { list_inventory(req, ctx, p) })
    }
    ["api", "moves"] ->
      case req.method {
        Get -> {
          use <- need(p, rbac.move_execute)
          list_moves(req, ctx, p)
        }
        Post -> {
          use <- need(p, rbac.move_execute)
          create_move(req, ctx, p)
        }
        _ -> json_error(405, "方法不允许")
      }
    ["api", "picking", "from-order", bill_id] -> {
      use <- need(p, rbac.picking_execute)
      only(req, Get, fn() { picking_task(ctx, p, bill_id) })
    }
    ["api", "waves", "candidates"] -> {
      use <- need(p, rbac.order_view)
      only(req, Get, fn() { wave_candidates(req, ctx, p) })
    }
    ["api", "waves"] ->
      case req.method {
        Get -> {
          use <- need(p, rbac.order_view)
          list_waves(req, ctx, p)
        }
        Post -> {
          use <- need(p, rbac.wave_manage)
          create_wave(req, ctx, p)
        }
        _ -> json_error(405, "方法不允许")
      }
    ["api", "waves", wave_id, "cancel"] -> {
      use <- need(p, rbac.wave_manage)
      only(req, Post, fn() { cancel_wave(ctx, p, wave_id) })
    }
    ["api", "waves", wave_id] -> {
      use <- need(p, rbac.order_view)
      only(req, Get, fn() { wave_detail(ctx, p, wave_id) })
    }
    _ -> wisp.not_found()
  }
}

fn only(req: Request, method: Method, handler: fn() -> Response) -> Response {
  case req.method == method {
    True -> handler()
    False -> json_error(405, "方法不允许")
  }
}

fn need(p: Principal, permission: String, handler: fn() -> Response) -> Response {
  case access.has(p, permission) {
    True -> handler()
    False -> json_error(403, "没有权限执行该操作")
  }
}

fn in_scope(p: Principal, warehouse_id: Int, handler: fn() -> Response) -> Response {
  case rbac.allows(p.scope, warehouse_id) {
    True -> handler()
    False -> json_error(403, "无权访问该仓库")
  }
}

/// 写库存的作业只能在当前作业仓库进行,防止跨仓串单。
fn at_station(p: Principal, warehouse_id: Int, handler: fn() -> Response) -> Response {
  use <- in_scope(p, warehouse_id)
  case p.current_warehouse {
    0 -> json_error(409, "请先选择当前作业仓库")
    current if current == warehouse_id -> handler()
    _ -> json_error(409, "单据不属于当前作业仓库")
  }
}

fn bearer_token(req: Request) -> String {
  case request.get_header(req, "authorization") {
    Error(_) -> ""
    Ok(value) ->
      case string.split_once(string.trim(value), " ") {
        Ok(#(scheme, token)) ->
          case string.lowercase(scheme) {
            "bearer" -> string.trim(token)
            _ -> ""
          }
        Error(_) -> ""
      }
  }
}

fn auth_error(err: access.AuthError) -> Response {
  case err {
    access.Unauthorized(message) -> json_error(401, message)
    access.Forbidden(message) -> json_error(403, message)
    access.Rejected(message) -> json_error(409, message)
    access.NotFound(message) -> json_error(404, message)
    access.AuthDb(database.DbError(message)) -> json_error(500, message)
  }
}

fn login(req: Request, ctx: Context) -> Response {
  use body <- wisp.require_json(req)
  let decoder = {
    use username <- decode.field("username", decode.string)
    use secret <- decode.field("password", decode.string)
    use terminal <- decode.field("terminal", decode.string)
    decode.success(#(username, secret, terminal))
  }
  case decode.run(body, decoder) {
    Error(_) -> json_error(400, "需要 username、password、terminal")
    Ok(#(username, secret, terminal)) ->
      case access.login(ctx.db, ctx.auth, username, secret, terminal) {
        Error(err) -> auth_error(err)
        Ok(#(token, p)) ->
          json_ok([
            #("token", json.string(token)),
            #("expires_at", json.string(p.expires_at)),
            #("user", principal_json(p)),
          ])
      }
  }
}

fn me(p: Principal) -> Response {
  json_ok([#("user", principal_json(p))])
}

fn logout(ctx: Context, p: Principal) -> Response {
  case access.logout(ctx.db, p) {
    Error(err) -> auth_error(err)
    Ok(_) -> json_ok([#("success", json.bool(True))])
  }
}

fn select_warehouse(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, decode.at(["warehouse_id"], json_codec.int())) {
    Error(_) -> json_error(400, "需要 warehouse_id")
    Ok(warehouse_id) ->
      case access.select_warehouse(ctx.db, p, warehouse_id) {
        Error(err) -> auth_error(err)
        Ok(p) -> json_ok([#("user", principal_json(p))])
      }
  }
}

fn change_password(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  let decoder = {
    use old <- decode.field("old_password", decode.string)
    use new <- decode.field("new_password", decode.string)
    decode.success(#(old, new))
  }
  case decode.run(body, decoder) {
    Error(_) -> json_error(400, "需要 old_password、new_password")
    Ok(#(old, new)) ->
      case access.change_password(ctx.db, p, old, new) {
        Error(err) -> auth_error(err)
        Ok(_) -> json_ok([#("success", json.bool(True))])
      }
  }
}

fn list_users(ctx: Context) -> Response {
  case access.list_users(ctx.db) {
    Error(err) -> auth_error(err)
    Ok(users) -> json_ok([#("users", json.array(users, of: user_json))])
  }
}

fn create_user(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  let decoder = {
    use username <- decode.field("username", decode.string)
    use secret <- decode.field("password", decode.string)
    use input <- decode.then(user_input_decoder())
    decode.success(#(username, secret, input))
  }
  case decode.run(body, decoder) {
    Error(_) ->
      json_error(400, "需要 username、password、display_name、roles、warehouse_ids")
    Ok(#(username, secret, input)) ->
      case access.create_user(ctx.db, p, username, secret, input) {
        Error(err) -> auth_error(err)
        Ok(user) -> json_ok([#("user", user_json(user))])
      }
  }
}

fn update_user(req: Request, ctx: Context, p: Principal, user_id: String) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, user_input_decoder()) {
    Error(_) -> json_error(400, "需要 display_name、roles、warehouse_ids")
    Ok(input) ->
      case access.update_user(ctx.db, p, user_id, input) {
        Error(err) -> auth_error(err)
        Ok(user) -> json_ok([#("user", user_json(user))])
      }
  }
}

fn reset_password(req: Request, ctx: Context, p: Principal, user_id: String) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, decode.at(["password"], decode.string)) {
    Error(_) -> json_error(400, "需要 password")
    Ok(secret) ->
      case access.reset_password(ctx.db, p, user_id, secret) {
        Error(err) -> auth_error(err)
        Ok(_) -> json_ok([#("success", json.bool(True))])
      }
  }
}

fn user_input_decoder() -> decode.Decoder(access.UserInput) {
  use display_name <- decode.field("display_name", decode.string)
  use enabled <- decode.optional_field("enabled", True, decode.bool)
  use roles <- decode.field("roles", decode.list(decode.string))
  use warehouse_ids <- decode.field("warehouse_ids", decode.list(json_codec.int()))
  decode.success(access.UserInput(
    display_name: display_name,
    enabled: enabled,
    roles: roles,
    warehouse_ids: warehouse_ids,
  ))
}

fn list_roles(ctx: Context) -> Response {
  case database.transaction(ctx.db, user_repo.list_roles) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(roles) -> {
      let names =
        rbac.permissions()
        |> list.map(fn(permission) { #(permission.code, permission.name) })
      json_ok([
        #(
          "roles",
          json.array(roles, of: fn(role: user_repo.RoleRow) {
            json.object([
              #("code", json.string(role.code)),
              #("name", json.string(role.name)),
              #("terminals", json.array(role.terminals, of: json.string)),
              #("permissions", json.array(role.permissions, of: json.string)),
            ])
          }),
        ),
        #(
          "permissions",
          json.array(names, of: fn(pair) {
            json.object([
              #("code", json.string(pair.0)),
              #("name", json.string(pair.1)),
            ])
          }),
        ),
      ])
    }
  }
}

fn list_audit(req: Request, ctx: Context) -> Response {
  let limit = query_limit(req)
  let offset = query_offset(req)
  case database.transaction(ctx.db, fn(conn) {
    user_repo.list_audit(conn, limit, offset)
  }) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(#(total, entries)) ->
      json_ok([
        #("total", json.int(total)),
        #("limit", json.int(limit)),
        #("offset", json.int(offset)),
        #(
          "logs",
          json.array(entries, of: fn(entry: user_repo.AuditEntry) {
            json.object([
              #("id", json.string(entry.id)),
              #("user_id", json.string(entry.user_id)),
              #("username", json.string(entry.username)),
              #("action", json.string(entry.action)),
              #("target", json.string(entry.target)),
              #("warehouse_id", json.int(entry.warehouse_id)),
              #("detail", json.string(entry.detail)),
              #("created_at", json.string(entry.created_at)),
            ])
          }),
        ),
      ])
  }
}

fn principal_json(p: Principal) -> json.Json {
  json.object([
    #("id", json.string(p.user_id)),
    #("username", json.string(p.username)),
    #("display_name", json.string(p.display_name)),
    #("roles", json.array(p.roles, of: json.string)),
    #("permissions", json.array(p.permissions, of: json.string)),
    #("all_warehouses", json.bool(p.scope == rbac.AllWarehouses)),
    #("warehouse_ids", json.array(p.warehouse_ids, of: json.int)),
    #("current_warehouse_id", json.int(p.current_warehouse)),
    #("terminal", json.string(p.terminal)),
    #("expires_at", json.string(p.expires_at)),
  ])
}

fn user_json(user: user_repo.User) -> json.Json {
  json.object([
    #("id", json.string(user.id)),
    #("username", json.string(user.username)),
    #("display_name", json.string(user.display_name)),
    #("enabled", json.bool(user.enabled)),
    #("roles", json.array(user.roles, of: json.string)),
    #("warehouse_ids", json.array(user.warehouse_ids, of: json.int)),
    #("created_at", json.string(user.created_at)),
    #("updated_at", json.string(user.updated_at)),
  ])
}

fn audit_sync(ctx: Context, p: Principal, report: sync_report.SyncReport) -> Nil {
  access.audit(
    ctx.db,
    p,
    "sync." <> report.entity,
    report.entity,
    0,
    "拉取 "
      <> int.to_string(report.fetched)
      <> ",写入 "
      <> int.to_string(report.upserted),
  )
}

fn home_page(ctx: Context) -> Response {
  json_ok([
    #("system", json.string("SwiftFlow WMS")),
    #("status", json.string("running")),
    #("database", json.string(driver_name(ctx))),
  ])
}

fn health(ctx: Context) -> Response {
  let probe =
    database.transaction(ctx.db, fn(conn) {
      database.query_on(conn, "SELECT 1", [], {
        use value <- decode.field(0, json_codec.int())
        decode.success(value)
      })
    })
  case probe {
    Ok(_) ->
      json_ok([
        #("healthy", json.bool(True)),
        #("database", json.string(driver_name(ctx))),
      ])
    Error(database.DbError(message)) ->
      wisp.json_response(
        json.to_string(
          json.object([
            #("healthy", json.bool(False)),
            #("error", json.string(message)),
          ]),
        ),
        500,
      )
  }
}

fn ql361_status(ctx: Context) -> Response {
  let runs = read_runs(ctx)
  json_ok([
    #(
      "credentials_configured",
      json.bool(
        ctx.config.ql361_username != "" && ctx.config.ql361_password != "",
      ),
    ),
    #("ql361_username", json.string(ctx.config.ql361_username)),
    #("session_cached", json.bool(session.cached(ctx.session))),
    #("base_url", json.string(ctx.config.ql361_base_url)),
    #("order_poll_seconds", json.int(ctx.config.order_poll_ms / 1000)),
    #("request_gap_ms", json.int(ctx.config.request_gap_ms)),
    #("order_begin_date", json.string(ctx.config.order_begin_date)),
    #(
      "stock_warehouses",
      json.array(ctx.config.stock_warehouses, of: json.string),
    ),
    #("database", json.string(driver_name(ctx))),
    #("recent_runs", runs_json(runs)),
  ])
}

fn ql361_login(ctx: Context) -> Response {
  case session.ensure(ctx.session) {
    Ok(#(_, info)) ->
      json_ok([
        #("success", json.bool(True)),
        #("company_name", json.string(info.company_name)),
        #("agent_real_name", json.string(info.agent_real_name)),
      ])
    Error(err) -> json_error(400, ql361api.describe_ql361_error(err))
  }
}

fn sync_status(ctx: Context) -> Response {
  json_ok([#("runs", runs_json(read_runs(ctx)))])
}

fn sync_products_now(req: Request, ctx: Context, p: Principal) -> Response {
  case product_mode(req) {
    Error(message) -> json_error(400, message)
    Ok(mode) -> sync_response(ctx, p, sync_products.run(ctx, "manual", mode))
  }
}

fn sync_purchase_receipts_now(ctx: Context, p: Principal) -> Response {
  sync_response(ctx, p, sync_purchase_receipts.run(ctx, "manual"))
}

fn sync_inbounds_now(ctx: Context, p: Principal) -> Response {
  sync_response(ctx, p, sync_inbounds.run(ctx, "manual"))
}

fn sync_orders_now(ctx: Context, p: Principal) -> Response {
  sync_response(ctx, p, sync_orders.run(ctx, "manual"))
}

fn sync_response(
  ctx: Context,
  p: Principal,
  outcome: Result(sync_report.SyncReport, sync_report.SyncError),
) -> Response {
  case outcome {
    Ok(report) -> {
      audit_sync(ctx, p, report)
      json_ok(report_json(report))
    }
    Error(sync_report.SyncBusy) -> json_error(409, sync_report.describe(sync_report.SyncBusy))
    Error(err) -> json_error(502, sync_report.describe(err))
  }
}

fn list_warehouses(ctx: Context, p: Principal) -> Response {
  case database.transaction(ctx.db, repo.list_warehouses) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(warehouses) ->
      json_ok([
        #(
          "warehouses",
          warehouses
          |> list.filter(fn(row: repo.StoredWarehouse) {
            rbac.allows(p.scope, row.warehouse.id)
          })
          |> json.array(of: fn(row: repo.StoredWarehouse) {
            json.object([
              #("id", json.int(row.warehouse.id)),
              #("code", json.string(row.warehouse.code)),
              #("name", json.string(row.warehouse.fullname)),
              #("easycode", json.string(row.warehouse.easycode)),
              #("operational", json.bool(row.operational)),
            ])
          }),
        ),
      ])
  }
}

fn list_locations(req: Request, ctx: Context, p: Principal) -> Response {
  case int.parse(query_value(req, "warehouse_id", "")) {
    Error(_) -> json_error(400, "需要 warehouse_id")
    Ok(warehouse_id) -> {
      use <- in_scope(p, warehouse_id)
      case database.transaction(ctx.db, fn(conn) {
        use warehouse <- result.try(repo.get_warehouse(conn, warehouse_id))
        use rows <- result.try(repo.list_locations(conn, warehouse_id))
        Ok(#(warehouse, rows))
      }) {
        Error(database.DbError(message)) -> json_error(500, message)
        Ok(#(None, _)) -> json_error(404, "仓库不存在")
        Ok(#(Some(warehouse), rows)) ->
          json_ok([
            #("warehouse_id", json.int(warehouse_id)),
            #("operational", json.bool(warehouse.operational)),
            #("locations", json.array(rows, of: location_json)),
          ])
      }
    }
  }
}

fn create_location(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, location_input_decoder()) {
    Error(_) -> json_error(400, "库位参数无法解析")
    Ok(#(warehouse_id, area, aisle, level, bin)) -> {
      use <- in_scope(p, warehouse_id)
      case location.build(area, aisle, level, bin) {
        Error(message) -> json_error(400, message)
        Ok(code) -> save_location(ctx, p, warehouse_id, code)
      }
    }
  }
}

fn save_location(
  ctx: Context,
  p: Principal,
  warehouse_id: Int,
  code: location.LocationCode,
) -> Response {
  let now = clock.now_local()
  case database.transaction(ctx.db, fn(conn) {
    use warehouse <- result.try(repo.get_warehouse(conn, warehouse_id))
    case warehouse {
      None -> Ok(Error("仓库不存在"))
      Some(row) ->
        case row.operational {
          False -> Ok(Error("该仓库只作显示,不能建立库存库位"))
          True -> {
            use existing <- result.try(repo.find_location(conn, warehouse_id, code.code))
            case existing {
              Some(_) -> Ok(Error("库位已存在"))
              None -> {
                let row =
                  Location(
                    warehouse_id: warehouse_id,
                    area: code.area,
                    aisle: code.aisle,
                    level: code.level,
                    bin: code.bin,
                    code: code.code,
                    barcode: location.barcode(row.warehouse.code, code.compact),
                    enabled: True,
                    created_at: now,
                    updated_at: now,
                  )
                use _ <- result.try(repo.insert_location(conn, row))
                Ok(Ok(row))
              }
            }
          }
        }
    }
  }) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(Error(message)) ->
      case message {
        "仓库不存在" -> json_error(404, message)
        "该仓库只作显示,不能建立库存库位" -> json_error(409, message)
        _ -> json_error(409, message)
      }
    Ok(Ok(row)) -> {
      access.audit(ctx.db, p, "location.create", row.code, warehouse_id, "")
      json_ok([#("location", location_json(row))])
    }
  }
}

fn disable_location(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, disable_location_decoder()) {
    Error(_) -> json_error(400, "库位参数无法解析")
    Ok(#(warehouse_id, code)) -> {
      use <- in_scope(p, warehouse_id)
      let code = string.uppercase(string.trim(code))
      case database.transaction(ctx.db, fn(conn) {
        repo.set_location_enabled(conn, warehouse_id, code, False, clock.now_local())
      }) {
        Error(database.DbError(message)) -> json_error(500, message)
        Ok(False) -> json_error(404, "库位不存在")
        Ok(True) -> {
          access.audit(ctx.db, p, "location.disable", code, warehouse_id, "")
          json_ok([#("disabled", json.bool(True)), #("code", json.string(code))])
        }
      }
    }
  }
}

fn plan_locations(req: Request, ctx: Context, p: Principal, commit: Bool) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, plan_decoder()) {
    Error(_) -> json_error(400, "库位规划参数无法解析")
    Ok(plan) -> {
      use <- in_scope(p, plan.warehouse_id)
      case location_plan.run(ctx.db, plan, commit) {
        Error(location_plan.PlanRejected(message)) -> json_error(409, message)
        Error(location_plan.PlanMissingWarehouse) -> json_error(404, "仓库不存在")
        Error(location_plan.PlanDb(database.DbError(message))) -> json_error(500, message)
        Ok(outcome) -> {
          case commit {
            True ->
              access.audit(
                ctx.db,
                p,
                "location.plan",
                string.join(plan.areas, ","),
                plan.warehouse_id,
                "新建 " <> int.to_string(list.length(outcome.created)) <> " 个库位",
              )
            False -> Nil
          }
          json_ok([
            #("committed", json.bool(commit)),
            #("warehouse_id", json.int(plan.warehouse_id)),
            #("new_count", json.int(list.length(outcome.created))),
            #("existing_count", json.int(list.length(outcome.existing))),
            #("locations", json.array(outcome.created, of: location_json)),
            #("existing", json.array(outcome.existing, of: json.string)),
          ])
        }
      }
    }
  }
}

fn plan_decoder() -> decode.Decoder(location_plan.Plan) {
  let range = {
    use from <- decode.field("from", json_codec.int())
    use to <- decode.field("to", json_codec.int())
    use width <- decode.optional_field("width", 2, json_codec.int())
    decode.success(location_plan.Range(from: from, to: to, width: width))
  }
  use warehouse_id <- decode.field("warehouse_id", json_codec.int())
  use areas <- decode.field("areas", decode.list(decode.string))
  use aisle <- decode.field("aisle", range)
  use level <- decode.field("level", range)
  use bin <- decode.field("bin", range)
  decode.success(location_plan.Plan(
    warehouse_id: warehouse_id,
    areas: areas,
    aisle: aisle,
    level: level,
    bin: bin,
  ))
}

fn location_input_decoder() -> decode.Decoder(#(Int, String, String, String, String)) {
  use warehouse_id <- decode.field("warehouse_id", json_codec.int())
  use area <- decode.field("area", decode.string)
  use aisle <- decode.field("aisle", decode.string)
  use level <- decode.field("level", decode.string)
  use bin <- decode.field("bin", decode.string)
  decode.success(#(warehouse_id, area, aisle, level, bin))
}

fn disable_location_decoder() -> decode.Decoder(#(Int, String)) {
  use warehouse_id <- decode.field("warehouse_id", json_codec.int())
  use code <- decode.field("code", decode.string)
  decode.success(#(warehouse_id, code))
}

fn location_json(row: Location) -> json.Json {
  json.object([
    #("warehouse_id", json.int(row.warehouse_id)),
    #("area", json.string(row.area)),
    #("aisle", json.string(row.aisle)),
    #("level", json.string(row.level)),
    #("bin", json.string(row.bin)),
    #("code", json.string(row.code)),
    #("barcode", json.string(row.barcode)),
    #("enabled", json.bool(row.enabled)),
    #("created_at", json.string(row.created_at)),
    #("updated_at", json.string(row.updated_at)),
  ])
}

fn list_products(req: Request, ctx: Context) -> Response {
  let keyword = query_value(req, "keyword", "")
  let limit = query_limit(req)
  let offset = query_offset(req)
  case database.transaction(ctx.db, fn(conn) { repo.list_products(conn, keyword, limit, offset) }) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(page) ->
      json_ok([
        #("total", json.int(page.total)),
        #("limit", json.int(limit)),
        #("offset", json.int(offset)),
        #("products", json.array(page.items, of: product_json)),
      ])
  }
}

fn list_orders(
  req: Request,
  ctx: Context,
  p: Principal,
  status: Option(Int),
) -> Response {
  let limit = query_limit(req)
  let offset = query_offset(req)
  case database.transaction(ctx.db, fn(conn) {
    repo.list_orders(conn, p.scope, status, limit, offset)
  }) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(page) ->
      json_ok([
        #("total", json.int(page.total)),
        #("limit", json.int(limit)),
        #("offset", json.int(offset)),
        #("orders", json.array(page.items, of: summary_json)),
      ])
  }
}

fn list_inbounds(
  req: Request,
  ctx: Context,
  p: Principal,
  bill_type: Int,
  status: Option(Int),
) -> Response {
  let limit = query_limit(req)
  let offset = query_offset(req)
  case database.transaction(ctx.db, fn(conn) {
    repo.list_inbounds(conn, p.scope, bill_type, status, limit, offset)
  }) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(page) ->
      json_ok([
        #("total", json.int(page.total)),
        #("limit", json.int(limit)),
        #("offset", json.int(offset)),
        #("inbounds", json.array(page.items, of: inbound_summary_json)),
      ])
  }
}

fn inbound_detail(ctx: Context, p: Principal, bill_id_str: String) -> Response {
  case int.parse(bill_id_str) {
    Error(_) -> json_error(400, "单据编号无效")
    Ok(bill_id) -> purchase_document(ctx, p, bill_id, "本地没有这张采购订单,请先同步")
  }
}

fn purchase_receipt_detail(ctx: Context, p: Principal, bill_id_str: String) -> Response {
  case int.parse(bill_id_str) {
    Error(_) -> json_error(400, "单据编号无效")
    Ok(bill_id) -> purchase_document(ctx, p, bill_id, "本地没有这张采购入库单,请先同步")
  }
}

fn batch_lookup(ctx: Context, p: Principal, batch_code: String) -> Response {
  case putaway.lookup(ctx.db, batch_code) {
    Error(putaway.PutawayRejected(message)) -> json_error(404, message)
    Error(putaway.PutawayDb(database.DbError(message))) -> json_error(500, message)
    Ok(#(label, put_away, produced_on, expires_on)) -> {
      use <- in_scope(p, label.warehouse_id)
      json_ok([
        #("batch_code", json.string(label.batch_code)),
        #("bill_id", json.int(label.bill_id)),
        #("bill_code", json.string(label.bill_code)),
        #("bill_detail_id", json.int(label.bill_detail_id)),
        #("warehouse_id", json.int(label.warehouse_id)),
        #("warehouse_name", json.string(label.warehouse_name)),
        #("sku_id", json.int(label.sku_id)),
        #("goods_code", json.string(label.goods_code)),
        #("goods_name", json.string(label.goods_name)),
        #("barcode", json.string(label.barcode)),
        #("base_qty", json.float(label.base_qty)),
        #("base_unit", json.string(label.base_unit)),
        #("put_away_qty", json.float(put_away)),
        #("produced_on", json.string(produced_on)),
        #("expires_on", json.string(expires_on)),
      ])
    }
  }
}

fn recommend_putaway(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  let decoder = {
    use batch_code <- decode.field("batch_code", decode.string)
    use expires_on <- decode.optional_field("expires_on", "", decode.string)
    decode.success(#(batch_code, expires_on))
  }
  case decode.run(body, decoder) {
    Error(_) -> json_error(400, "需要 batch_code")
    Ok(#(batch_code, expires_on)) ->
      case putaway.suggest(ctx.db, batch_code, expires_on) {
        Error(err) -> putaway_error(err)
        Ok(target) -> {
          use <- at_station(p, target.label.warehouse_id)
          json_ok([
            #("batch_code", json.string(target.label.batch_code)),
            #("bill_id", json.int(target.label.bill_id)),
            #("bill_code", json.string(target.label.bill_code)),
            #("warehouse_id", json.int(target.label.warehouse_id)),
            #("warehouse_name", json.string(target.label.warehouse_name)),
            #("goods_code", json.string(target.label.goods_code)),
            #("goods_name", json.string(target.label.goods_name)),
            #("barcode", json.string(target.label.barcode)),
            #("base_qty", json.float(target.label.base_qty)),
            #("base_unit", json.string(target.label.base_unit)),
            #("put_away_qty", json.float(target.put_away_qty)),
            #("produced_on", json.string(target.produced_on)),
            #("expires_on", json.string(target.expires_on)),
            #("dates_locked", json.bool(target.produced_on != "")),
            #("location_code", json.string(target.suggestion.location_code)),
            #("reason", json.string(target.suggestion.reason)),
          ])
        }
      }
  }
}

fn create_putaway(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, putaway_input_decoder()) {
    Error(_) -> json_error(400, "上架参数无法解析")
    Ok(input) ->
      case putaway.lookup(ctx.db, input.batch_code) {
        Error(err) -> putaway_error(err)
        Ok(#(label, _, _, _)) -> {
          use <- at_station(p, label.warehouse_id)
          case putaway.create(ctx.db, input) {
            Error(err) -> putaway_error(err)
            Ok(row) -> {
              access.audit(
                ctx.db,
                p,
                "putaway.create",
                row.batch_code,
                row.warehouse_id,
                float.to_string(row.qty) <> " 上架到 " <> row.location_code,
              )
              json_ok([#("putaway", putaway_json(row))])
            }
          }
        }
      }
  }
}

fn putaway_error(err: putaway.PutawayError) -> Response {
  case err {
    putaway.PutawayRejected(message) -> json_error(409, message)
    putaway.PutawayDb(database.DbError(message)) -> json_error(500, message)
  }
}

fn putaway_input_decoder() -> decode.Decoder(putaway.PutawayInput) {
  use batch_code <- decode.field("batch_code", decode.string)
  use qty <- decode.field("qty", json_codec.number())
  use produced_on <- decode.field("produced_on", decode.string)
  use expires_on <- decode.field("expires_on", decode.string)
  use location_code <- decode.field("location_code", decode.string)
  decode.success(putaway.PutawayInput(
    batch_code: batch_code,
    qty: qty,
    produced_on: produced_on,
    expires_on: expires_on,
    location_code: location_code,
  ))
}

fn putaway_json(row: repo.Putaway) -> json.Json {
  json.object([
    #("id", json.string(row.id)),
    #("batch_code", json.string(row.batch_code)),
    #("bill_id", json.int(row.bill_id)),
    #("bill_detail_id", json.int(row.bill_detail_id)),
    #("warehouse_id", json.int(row.warehouse_id)),
    #("sku_id", json.int(row.sku_id)),
    #("goods_code", json.string(row.goods_code)),
    #("goods_name", json.string(row.goods_name)),
    #("location_code", json.string(row.location_code)),
    #("produced_on", json.string(row.produced_on)),
    #("expires_on", json.string(row.expires_on)),
    #("qty", json.float(row.qty)),
    #("created_at", json.string(row.created_at)),
  ])
}

fn purchase_document(
  ctx: Context,
  p: Principal,
  bill_id: Int,
  missing: String,
) -> Response {
  case database.transaction(ctx.db, fn(conn) {
    use order <- result.try(repo.get_inbound(conn, bill_id))
    use codes <- result.try(repo.list_batch_codes(conn, bill_id))
    Ok(#(order, codes))
  }) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(#(None, _)) -> json_error(404, missing)
    Ok(#(Some(order), codes)) -> {
      use <- in_scope(p, order.warehouse_id)
      json_ok([#("inbound", inbound_json(order, codes))])
    }
  }
}

/// 读出采购单所属仓库,用于范围校验。
fn with_inbound_warehouse(
  ctx: Context,
  bill_id: Int,
  next: fn(Int) -> Response,
) -> Response {
  case database.transaction(ctx.db, fn(conn) { repo.get_inbound(conn, bill_id) }) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(None) -> json_error(404, "本地没有这张采购单,请先同步")
    Ok(Some(order)) -> next(order.warehouse_id)
  }
}

fn with_order_warehouse(
  ctx: Context,
  bill_id: Int,
  next: fn(Int) -> Response,
) -> Response {
  case database.transaction(ctx.db, fn(conn) { repo.get_order(conn, bill_id) }) {
    Error(database.DbError(message)) -> json_error(500, message)
    Ok(None) -> json_error(404, "本地没有这张订单,请先同步订单")
    Ok(Some(order)) -> next(order.warehouse_id)
  }
}

fn list_receipts(ctx: Context, p: Principal, bill_id_str: String) -> Response {
  case int.parse(bill_id_str) {
    Error(_) -> json_error(400, "单据编号无效")
    Ok(bill_id) -> {
      use warehouse_id <- with_inbound_warehouse(ctx, bill_id)
      use <- in_scope(p, warehouse_id)
      case database.transaction(ctx.db, fn(conn) { repo.list_receipts(conn, bill_id) }) {
        Error(database.DbError(message)) -> json_error(500, message)
        Ok(rows) -> json_ok([#("receipts", json.array(rows, of: receipt_json))])
      }
    }
  }
}

fn create_receipt(req: Request, ctx: Context, p: Principal, bill_id_str: String) -> Response {
  case int.parse(bill_id_str) {
    Error(_) -> json_error(400, "单据编号无效")
    Ok(bill_id) -> {
      use body <- wisp.require_json(req)
      use warehouse_id <- with_inbound_warehouse(ctx, bill_id)
      use <- at_station(p, warehouse_id)
      case decode.run(body, receipt_input_decoder()) {
        Error(_) -> json_error(400, "收货参数无法解析")
        Ok(lines) ->
          case receiving.create(ctx.db, bill_id, lines) {
            Ok(receipt) -> {
              access.audit(
                ctx.db,
                p,
                "receipt.create",
                receipt.id,
                receipt.warehouse_id,
                "采购单 " <> int.to_string(bill_id) <> ","
                  <> int.to_string(list.length(receipt.lines)) <> " 行",
              )
              json_ok([#("receipt", receipt_json(receipt))])
            }
            Error(receiving.ReceiptRejected(message)) -> json_error(409, message)
            Error(receiving.ReceiptDb(database.DbError(message))) -> json_error(500, message)
          }
      }
    }
  }
}

fn list_inventory(req: Request, ctx: Context, p: Principal) -> Response {
  case int.parse(query_value(req, "warehouse_id", "")) {
    Error(_) -> json_error(400, "需要 warehouse_id")
    Ok(warehouse_id) -> {
      use <- in_scope(p, warehouse_id)
      case database.transaction(ctx.db, fn(conn) {
        use warehouse <- result.try(repo.get_warehouse(conn, warehouse_id))
        use rows <- result.try(repo.list_inventory(conn, warehouse_id))
        Ok(#(warehouse, rows))
      }) {
        Error(database.DbError(message)) -> json_error(500, message)
        Ok(#(None, _)) -> json_error(404, "仓库不存在")
        Ok(#(Some(warehouse), rows)) ->
          case warehouse.operational {
            False -> json_error(409, "该仓库只作显示,没有库存")
            True ->
              json_ok([
                #("warehouse_id", json.int(warehouse_id)),
                #("batches", json.array(rows, of: inventory_json)),
              ])
          }
      }
    }
  }
}

fn receipt_input_decoder() -> decode.Decoder(List(receiving.LineInput)) {
  use lines <- decode.field(
    "lines",
    decode.list({
      use bill_detail_id <- decode.field("bill_detail_id", json_codec.int())
      use received_qty <- decode.field("received_qty", json_codec.number())
      use batch_no <- decode.field("batch_no", decode.string)
      use produced_on <- decode.field("produced_on", decode.string)
      use expires_on <- decode.field("expires_on", decode.string)
      use location_code <- decode.field("location_code", decode.string)
      decode.success(receiving.LineInput(
        bill_detail_id: bill_detail_id,
        received_qty: received_qty,
        batch_no: batch_no,
        produced_on: produced_on,
        expires_on: expires_on,
        location_code: location_code,
      ))
    }),
  )
  decode.success(lines)
}

fn receipt_json(receipt: repo.Receipt) -> json.Json {
  json.object([
    #("id", json.string(receipt.id)),
    #("bill_id", json.int(receipt.bill_id)),
    #("warehouse_id", json.int(receipt.warehouse_id)),
    #("created_at", json.string(receipt.created_at)),
    #("lines", json.array(receipt.lines, of: receipt_line_json)),
  ])
}

fn receipt_line_json(line: repo.ReceiptLine) -> json.Json {
  json.object([
    #("line_no", json.int(line.line_no)),
    #("bill_detail_id", json.int(line.bill_detail_id)),
    #("sku_id", json.int(line.sku_id)),
    #("goods_code", json.string(line.goods_code)),
    #("goods_name", json.string(line.goods_name)),
    #("barcode", json.string(line.barcode)),
    #("received_qty", json.float(line.received_qty)),
    #("batch_no", json.string(line.batch_no)),
    #("produced_on", json.string(line.produced_on)),
    #("expires_on", json.string(line.expires_on)),
    #("location_code", json.string(line.location_code)),
  ])
}

fn inventory_json(batch: repo.InventoryBatch) -> json.Json {
  json.object([
    #("warehouse_id", json.int(batch.warehouse_id)),
    #("location_code", json.string(batch.location_code)),
    #("sku_id", json.int(batch.sku_id)),
    #("goods_code", json.string(batch.goods_code)),
    #("batch_no", json.string(batch.batch_no)),
    #("produced_on", json.string(batch.produced_on)),
    #("expires_on", json.string(batch.expires_on)),
    #("qty", json.float(batch.qty)),
  ])
}

fn list_shipments(ctx: Context, p: Principal, bill_id_str: String) -> Response {
  case int.parse(bill_id_str) {
    Error(_) -> json_error(400, "单据编号无效")
    Ok(bill_id) ->
      case database.transaction(ctx.db, fn(conn) {
        use order <- result.try(repo.get_order(conn, bill_id))
        use rows <- result.try(repo.list_shipments(conn, bill_id))
        Ok(#(order, rows))
      }) {
        Error(database.DbError(message)) -> json_error(500, message)
        Ok(#(None, _)) -> json_error(404, "本地没有这张订单,请先同步订单")
        Ok(#(Some(order), rows)) -> {
          use <- in_scope(p, order.warehouse_id)
          json_ok([#("shipments", json.array(rows, of: shipment_json))])
        }
      }
  }
}

fn create_shipment(req: Request, ctx: Context, p: Principal, bill_id_str: String) -> Response {
  case int.parse(bill_id_str) {
    Error(_) -> json_error(400, "单据编号无效")
    Ok(bill_id) -> {
      use body <- wisp.require_json(req)
      use warehouse_id <- with_order_warehouse(ctx, bill_id)
      use <- at_station(p, warehouse_id)
      case decode.run(body, shipment_input_decoder()) {
        Error(_) -> json_error(400, "出库参数无法解析")
        Ok(lines) ->
          case shipping.create(ctx.db, bill_id, lines) {
            Ok(shipment) -> {
              access.audit(
                ctx.db,
                p,
                "shipment.create",
                shipment.id,
                shipment.warehouse_id,
                "销售订单 " <> int.to_string(bill_id) <> ","
                  <> int.to_string(list.length(shipment.lines)) <> " 个批次行",
              )
              json_ok([#("shipment", shipment_json(shipment))])
            }
            Error(shipping.ShipmentRejected(message)) -> json_error(409, message)
            Error(shipping.ShipmentDb(database.DbError(message))) -> json_error(500, message)
          }
      }
    }
  }
}

fn shipment_input_decoder() -> decode.Decoder(List(shipping.ShipLine)) {
  use lines <- decode.field(
    "lines",
    decode.list({
      use bill_detail_id <- decode.field("bill_detail_id", json_codec.int())
      use qty <- decode.field("qty", json_codec.number())
      decode.success(shipping.ShipLine(bill_detail_id: bill_detail_id, qty: qty))
    }),
  )
  decode.success(lines)
}

fn shipment_json(shipment: repo.Shipment) -> json.Json {
  json.object([
    #("id", json.string(shipment.id)),
    #("bill_id", json.int(shipment.bill_id)),
    #("warehouse_id", json.int(shipment.warehouse_id)),
    #("created_at", json.string(shipment.created_at)),
    #("lines", json.array(shipment.lines, of: shipment_line_json)),
  ])
}

fn shipment_line_json(line: repo.ShipmentLine) -> json.Json {
  json.object([
    #("line_no", json.int(line.line_no)),
    #("bill_detail_id", json.int(line.bill_detail_id)),
    #("sku_id", json.int(line.sku_id)),
    #("goods_code", json.string(line.goods_code)),
    #("goods_name", json.string(line.goods_name)),
    #("batch_no", json.string(line.batch_no)),
    #("produced_on", json.string(line.produced_on)),
    #("expires_on", json.string(line.expires_on)),
    #("location_code", json.string(line.location_code)),
    #("qty", json.float(line.qty)),
  ])
}

fn order_detail(ctx: Context, p: Principal, bill_id_str: String) -> Response {
  case int.parse(bill_id_str) {
    Error(_) -> json_error(400, "单据编号无效")
    Ok(bill_id) ->
      case database.transaction(ctx.db, fn(conn) { repo.get_order(conn, bill_id) }) {
        Error(database.DbError(message)) -> json_error(500, message)
        Ok(None) -> json_error(404, "本地没有这张订单,请先同步订单")
        Ok(Some(order)) -> {
          use <- in_scope(p, order.warehouse_id)
          json_ok([#("order", order_json(order))])
        }
      }
  }
}

fn picking_task(ctx: Context, p: Principal, bill_id_str: String) -> Response {
  case int.parse(bill_id_str) {
    Error(_) -> json_error(400, "单据编号无效")
    Ok(bill_id) ->
      case database.transaction(ctx.db, fn(conn) {
        use order <- result.try(repo.get_order(conn, bill_id))
        use shipped_out <- result.try(repo.order_shipped_out(conn, bill_id))
        Ok(#(order, shipped_out))
      }) {
        Error(database.DbError(message)) -> json_error(500, message)
        Ok(#(None, _)) -> json_error(404, "本地没有这张订单,请先同步订单")
        Ok(#(Some(order), shipped_out)) -> {
          use <- in_scope(p, order.warehouse_id)
          case shipped_out, order.items {
            True, _ -> json_error(409, "这张订单已在本地发完,不再拣货")
            False, [] -> json_error(409, "订单明细尚未同步")
            False, _ -> {
              let task = picking.generate_single_order_picking(order)
              json_ok([#("task", picking.task_to_json(task))])
            }
          }
        }
      }
  }
}

fn read_runs(ctx: Context) -> List(repo.SyncRun) {
  case database.transaction(ctx.db, repo.list_runs) {
    Ok(runs) -> runs
    Error(_) -> []
  }
}

fn product_mode(req: Request) -> Result(sync_products.Mode, String) {
  case string.lowercase(query_value(req, "mode", "incremental")) {
    "" | "incremental" -> Ok(sync_products.Incremental)
    "full" -> Ok(sync_products.Full)
    _ -> Error("mode 只能是 incremental 或 full")
  }
}

fn query_value(req: Request, name: String, default: String) -> String {
  case list.key_find(wisp.get_query(req), name) {
    Ok(value) -> value
    Error(_) -> default
  }
}

fn query_limit(req: Request) -> Int {
  case int.parse(query_value(req, "limit", "50")) {
    Ok(value) if value > 200 -> 200
    Ok(value) if value > 0 -> value
    _ -> 50
  }
}

fn query_offset(req: Request) -> Int {
  case int.parse(query_value(req, "offset", "0")) {
    Ok(value) if value >= 0 -> value
    _ -> 0
  }
}

fn driver_name(ctx: Context) -> String {
  case ctx.config.db_driver {
    Sqlite -> "sqlite"
    Postgres -> "postgres"
  }
}

fn report_json(report: sync_report.SyncReport) -> List(#(String, json.Json)) {
  [
    #("success", json.bool(True)),
    #("entity", json.string(report.entity)),
    #("trigger", json.string(report.trigger)),
    #("fetched", json.int(report.fetched)),
    #("upserted", json.int(report.upserted)),
    #("cursor", json.string(report.cursor)),
  ]
}

fn runs_json(runs: List(repo.SyncRun)) -> json.Json {
  json.array(runs, of: fn(run) {
    json.object([
      #("id", json.string(run.id)),
      #("entity", json.string(run.entity)),
      #("trigger", json.string(run.trigger)),
      #("started_at", json.string(run.started_at)),
      #("finished_at", json.string(run.finished_at)),
      #("status", json.string(run.status)),
      #("fetched", json.int(run.fetched)),
      #("upserted", json.int(run.upserted)),
      #("error_message", json.string(run.error_message)),
    ])
  })
}

fn product_json(item: GoodsItem) -> json.Json {
  json.object([
    #("id", json.int(item.id)),
    #("code", json.string(item.code)),
    #("name", json.string(item.name)),
    #("barcode", json.string(item.barcode)),
    #("spec", json.string(item.spec)),
    #("unit", json.string(item.unit)),
    #("shelf_life", json.string(item.shelf_life)),
    #("shelf_life_days", json.int(item.shelf_life_days)),
    #("position_code", json.string(item.position_code)),
    #("disabled", json.bool(item.disabled)),
    #("modified_at", json.string(item.modified_at)),
    #("created_at", json.string(item.created_at)),
  ])
}

fn summary_json(order: SalesOrderSummary) -> json.Json {
  json.object([
    #("bill_id", json.int(order.bill_id)),
    #("bill_code", json.string(order.bill_code)),
    #("customer_name", json.string(order.customer_name)),
    #("contact_person", json.string(order.contact_person)),
    #("phone", json.string(order.phone)),
    #("warehouse_name", json.string(order.warehouse_name)),
    #("warehouse_id", json.int(order.warehouse_id)),
    #("bill_date", json.string(order.bill_date)),
    #("status", json.int(order.status)),
    #("status_name", json.string(order.status_name)),
    #("total_qty", json.float(order.total_qty)),
    #("total_money", json.float(order.total_money)),
    #("remark", json.string(order.remark)),
  ])
}

fn order_json(order: SalesOrder) -> json.Json {
  json.object([
    #("bill_id", json.int(order.bill_id)),
    #("bill_code", json.string(order.bill_code)),
    #("customer_name", json.string(order.customer_name)),
    #("contact_person", json.string(order.contact_person)),
    #("phone", json.string(order.phone)),
    #("warehouse_name", json.string(order.warehouse_name)),
    #("warehouse_id", json.int(order.warehouse_id)),
    #("bill_date", json.string(order.bill_date)),
    #("status", json.int(order.status)),
    #("status_name", json.string(order.status_name)),
    #("total_qty", json.float(order.total_qty)),
    #("total_money", json.float(order.total_money)),
    #("remark", json.string(order.remark)),
    #("items", json.array(order.items, of: item_json)),
  ])
}

fn inbound_summary_json(order: PurchaseOrderSummary) -> json.Json {
  json.object([
    #("bill_id", json.int(order.bill_id)),
    #("bill_code", json.string(order.bill_code)),
    #("supplier_name", json.string(order.supplier_name)),
    #("contact_person", json.string(order.contact_person)),
    #("phone", json.string(order.phone)),
    #("warehouse_name", json.string(order.warehouse_name)),
    #("warehouse_id", json.int(order.warehouse_id)),
    #("bill_date", json.string(order.bill_date)),
    #("status", json.int(order.status)),
    #("status_name", json.string(order.status_name)),
    #("total_qty", json.float(order.total_qty)),
    #("total_money", json.float(order.total_money)),
    #("remark", json.string(order.remark)),
  ])
}

fn inbound_json(
  order: PurchaseOrder,
  codes: List(#(Int, String)),
) -> json.Json {
  let codes = dict.from_list(codes)
  json.object([
    #("bill_id", json.int(order.bill_id)),
    #("bill_code", json.string(order.bill_code)),
    #("supplier_name", json.string(order.supplier_name)),
    #("contact_person", json.string(order.contact_person)),
    #("phone", json.string(order.phone)),
    #("warehouse_name", json.string(order.warehouse_name)),
    #("warehouse_id", json.int(order.warehouse_id)),
    #("bill_date", json.string(order.bill_date)),
    #("status", json.int(order.status)),
    #("status_name", json.string(order.status_name)),
    #("total_qty", json.float(order.total_qty)),
    #("total_money", json.float(order.total_money)),
    #("remark", json.string(order.remark)),
    #("items", json.array(order.items, of: inbound_item_json(codes))),
  ])
}

fn inbound_item_json(codes: dict.Dict(Int, String)) -> fn(PurchaseOrderItem) -> json.Json {
  fn(item: PurchaseOrderItem) {
    let code = case dict.get(codes, item.bill_detail_id) {
      Ok(code) -> code
      Error(_) -> ""
    }
    json.object([
    #("bill_detail_id", json.int(item.bill_detail_id)),
    #("batch_code", json.string(code)),
    #("sku_id", json.int(item.sku_id)),
    #("goods_code", json.string(item.goods_code)),
    #("goods_name", json.string(item.goods_name)),
    #("barcode", json.string(item.barcode)),
    #("spec", json.string(item.spec)),
    #("package_qty", json.float(item.package_qty)),
    #("package_unit", json.string(item.package_unit)),
    #("base_qty", json.float(item.base_qty)),
    #("base_unit", json.string(item.base_unit)),
    #("price", json.float(item.price)),
    #("total_money", json.float(item.total_money)),
    #("position_code", json.string(item.position_code)),
    #("area_code", json.string(item.area_code)),
    ])
  }
}

fn item_json(item: SalesOrderItem) -> json.Json {
  json.object([
    #("bill_detail_id", json.int(item.bill_detail_id)),
    #("sku_id", json.int(item.sku_id)),
    #("goods_code", json.string(item.goods_code)),
    #("goods_name", json.string(item.goods_name)),
    #("barcode", json.string(item.barcode)),
    #("spec", json.string(item.spec)),
    #("package_qty", json.float(item.package_qty)),
    #("package_unit", json.string(item.package_unit)),
    #("base_qty", json.float(item.base_qty)),
    #("base_unit", json.string(item.base_unit)),
    #("price", json.float(item.price)),
    #("total_money", json.float(item.total_money)),
    #("position_code", json.string(item.position_code)),
    #("area_code", json.string(item.area_code)),
  ])
}

fn wave_candidates(req: Request, ctx: Context, p: Principal) -> Response {
  case station_query(req, p) {
    Error(response) -> response
    Ok(warehouse_id) -> {
      let limit = query_limit(req)
      let offset = query_offset(req)
      case
        database.transaction(ctx.db, fn(conn) {
          repo.list_wave_candidates(conn, p.scope, warehouse_id, limit, offset)
        })
      {
        Error(database.DbError(message)) -> json_error(500, message)
        Ok(page) ->
          json_ok([
            #("total", json.int(page.total)),
            #("limit", json.int(limit)),
            #("offset", json.int(offset)),
            #("orders", json.array(page.items, of: summary_json)),
          ])
      }
    }
  }
}

fn list_waves(req: Request, ctx: Context, p: Principal) -> Response {
  case station_query(req, p) {
    Error(response) -> response
    Ok(warehouse_id) -> {
      let limit = query_limit(req)
      let offset = query_offset(req)
      case wave.list(ctx.db, warehouse_id, limit, offset) {
        Error(err) -> wave_error(err)
        Ok(#(total, rows)) ->
          json_ok([
            #("total", json.int(total)),
            #("limit", json.int(limit)),
            #("offset", json.int(offset)),
            #("waves", json.array(rows, of: wave_summary_json)),
          ])
      }
    }
  }
}

fn create_wave(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, decode.at(["bill_ids"], decode.list(json_codec.int()))) {
    Error(_) -> json_error(400, "需要 bill_ids")
    Ok(bill_ids) ->
      case p.current_warehouse {
        0 -> json_error(409, "请先选择当前作业仓库")
        warehouse_id -> {
          use <- in_scope(p, warehouse_id)
          case wave.create(ctx.db, bill_ids, p.username, warehouse_id) {
            Error(err) -> wave_error(err)
            Ok(row) -> {
              access.audit(
                ctx.db,
                p,
                "wave.create",
                row.id,
                row.warehouse_id,
                int.to_string(list.length(row.orders)) <> " 张订单",
              )
              json_ok([#("wave", wave_json(row))])
            }
          }
        }
      }
  }
}

fn wave_detail(ctx: Context, p: Principal, wave_id: String) -> Response {
  case wave.get(ctx.db, wave_id) {
    Error(err) -> wave_error(err)
    Ok(row) -> {
      use <- in_scope(p, row.warehouse_id)
      json_ok([#("wave", wave_json(row))])
    }
  }
}

fn cancel_wave(ctx: Context, p: Principal, wave_id: String) -> Response {
  case p.current_warehouse {
    0 -> json_error(409, "请先选择当前作业仓库")
    warehouse_id -> {
      use <- in_scope(p, warehouse_id)
      case wave.cancel(ctx.db, wave_id, warehouse_id) {
        Error(err) -> wave_error(err)
        Ok(row) -> {
          access.audit(ctx.db, p, "wave.cancel", row.id, row.warehouse_id, "")
          json_ok([#("wave", wave_json(row))])
        }
      }
    }
  }
}

fn station_query(req: Request, p: Principal) -> Result(Int, Response) {
  case int.parse(query_value(req, "warehouse_id", "0")) {
    Ok(warehouse_id) if warehouse_id > 0 ->
      case rbac.allows(p.scope, warehouse_id) {
        True -> Ok(warehouse_id)
        False -> Error(json_error(403, "无权访问该仓库"))
      }
    _ -> Error(json_error(409, "请先选择当前作业仓库"))
  }
}

fn wave_error(err: wave.WaveError) -> Response {
  case err {
    wave.WaveRejected("波次不存在") -> json_error(404, "波次不存在")
    wave.WaveRejected(message) -> json_error(409, message)
    wave.WaveDb(database.DbError(message)) -> json_error(500, message)
  }
}

fn wave_summary_json(row: wave.WaveSummary) -> json.Json {
  json.object([
    #("id", json.string(row.id)),
    #("warehouse_id", json.int(row.warehouse_id)),
    #("warehouse_name", json.string(row.warehouse_name)),
    #("status", json.string(row.status)),
    #("status_name", json.string(wave_status_name(row.status))),
    #("order_count", json.int(row.order_count)),
    #("pick_lines", json.int(row.pick_lines)),
    #("short_qty", json.float(row.short_qty)),
    #("created_by", json.string(row.created_by)),
    #("created_at", json.string(row.created_at)),
  ])
}

fn wave_json(row: wave.Wave) -> json.Json {
  json.object([
    #("id", json.string(row.id)),
    #("warehouse_id", json.int(row.warehouse_id)),
    #("warehouse_name", json.string(row.warehouse_name)),
    #("status", json.string(row.status)),
    #("status_name", json.string(wave_status_name(row.status))),
    #("created_by", json.string(row.created_by)),
    #("created_at", json.string(row.created_at)),
    #("orders", json.array(row.orders, of: wave_order_json)),
    #("picks", json.array(row.picks, of: wave_pick_json)),
  ])
}

fn wave_order_json(order: wave.WaveOrder) -> json.Json {
  json.object([
    #("bill_id", json.int(order.bill_id)),
    #("bill_code", json.string(order.bill_code)),
    #("customer_name", json.string(order.customer_name)),
  ])
}

fn wave_pick_json(line: wave.PickLine) -> json.Json {
  json.object([
    #("line_no", json.int(line.line_no)),
    #("sku_id", json.int(line.sku_id)),
    #("goods_code", json.string(line.goods_code)),
    #("goods_name", json.string(line.goods_name)),
    #("barcode", json.string(line.barcode)),
    #("spec", json.string(line.spec)),
    #("base_unit", json.string(line.base_unit)),
    #("location_code", json.string(line.location_code)),
    #("batch_no", json.string(line.batch_no)),
    #("produced_on", json.string(line.produced_on)),
    #("expires_on", json.string(line.expires_on)),
    #("qty", json.float(line.qty)),
    #("short_qty", json.float(line.short_qty)),
    #(
      "allocations",
      json.array(line.allocations, of: fn(allocation: wave.Allocation) {
        json.object([
          #("bill_id", json.int(allocation.bill_id)),
          #("bill_code", json.string(allocation.bill_code)),
          #("bill_detail_id", json.int(allocation.bill_detail_id)),
          #("qty", json.float(allocation.qty)),
        ])
      }),
    ),
  ])
}

fn wave_status_name(status: String) -> String {
  case status {
    "open" -> "待拣货"
    "cancelled" -> "已撤销"
    _ -> status
  }
}

fn list_moves(req: Request, ctx: Context, p: Principal) -> Response {
  case station_query(req, p) {
    Error(response) -> response
    Ok(warehouse_id) -> {
      let limit = query_limit(req)
      let offset = query_offset(req)
      case stock_move.list(ctx.db, warehouse_id, limit, offset) {
        Error(err) -> move_error(err)
        Ok(#(total, rows)) ->
          json_ok([
            #("total", json.int(total)),
            #("limit", json.int(limit)),
            #("offset", json.int(offset)),
            #("moves", json.array(rows, of: move_json)),
          ])
      }
    }
  }
}

fn create_move(req: Request, ctx: Context, p: Principal) -> Response {
  use body <- wisp.require_json(req)
  case decode.run(body, move_input_decoder()) {
    Error(_) -> json_error(400, "移库参数无法解析")
    Ok(input) ->
      case p.current_warehouse {
        0 -> json_error(409, "请先选择当前作业仓库")
        warehouse_id -> {
          use <- in_scope(p, warehouse_id)
          case stock_move.create(ctx.db, warehouse_id, input, p.username) {
            Error(err) -> move_error(err)
            Ok(row) -> {
              access.audit(
                ctx.db,
                p,
                "move.create",
                row.id,
                row.warehouse_id,
                row.from_location <> " -> " <> row.to_location,
              )
              json_ok([#("move", move_json(row))])
            }
          }
        }
      }
  }
}

fn move_input_decoder() -> decode.Decoder(stock_move.MoveInput) {
  use from_location <- decode.field("from_location", decode.string)
  use sku_id <- decode.field("sku_id", json_codec.int())
  use batch_no <- decode.field("batch_no", decode.string)
  use produced_on <- decode.field("produced_on", decode.string)
  use expires_on <- decode.field("expires_on", decode.string)
  use qty <- decode.field("qty", json_codec.number())
  use to_location <- decode.field("to_location", decode.string)
  decode.success(stock_move.MoveInput(
    from_location: from_location,
    sku_id: sku_id,
    batch_no: batch_no,
    produced_on: produced_on,
    expires_on: expires_on,
    qty: qty,
    to_location: to_location,
  ))
}

fn move_json(row: stock_move.Move) -> json.Json {
  json.object([
    #("id", json.string(row.id)),
    #("warehouse_id", json.int(row.warehouse_id)),
    #("from_location", json.string(row.from_location)),
    #("to_location", json.string(row.to_location)),
    #("sku_id", json.int(row.sku_id)),
    #("goods_code", json.string(row.goods_code)),
    #("batch_no", json.string(row.batch_no)),
    #("produced_on", json.string(row.produced_on)),
    #("expires_on", json.string(row.expires_on)),
    #("qty", json.float(row.qty)),
    #("created_by", json.string(row.created_by)),
    #("created_at", json.string(row.created_at)),
  ])
}

fn move_error(err: stock_move.MoveError) -> Response {
  case err {
    stock_move.MoveRejected("仓库不存在") -> json_error(404, "仓库不存在")
    stock_move.MoveRejected(message) -> json_error(409, message)
    stock_move.MoveDb(database.DbError(message)) -> json_error(500, message)
  }
}

fn json_ok(fields: List(#(String, json.Json))) -> Response {
  wisp.json_response(json.to_string(json.object(fields)), 200)
}

fn json_error(status: Int, message: String) -> Response {
  wisp.json_response(
    json.to_string(
      json.object([
        #("success", json.bool(False)),
        #("error", json.string(message)),
      ]),
    ),
    status,
  )
}

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.