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 6d9f5394f00169ded8480619049b8e42998eada100f8c266185d588e97579d7c:


import app.{type Context}
import clock
import config
import database
import gleam/list
import gleam/result
import ql361api.{type PurchaseOrder, type PurchaseOrderSummary}
import ql361api/purchases as ql_purchases
import repo.{SyncRun}
import session
import sync_lock
import warehouse_scope
import sync_report.{
  type SyncError, type SyncReport, SyncBusy, SyncDb, SyncNotConfigured, SyncQl361,
  SyncRejected, SyncReport,
}
import wisp

const max_pages = 500

pub fn run(ctx: Context, trigger: String) -> Result(SyncReport, SyncError) {
  case ctx.config.ql361_username != "" && ctx.config.ql361_password != "" {
    False -> Error(SyncNotConfigured)
    True ->
      case
        sync_lock.with_lock(ctx.lock, "purchase_receipts", fn() {
          do_sync(ctx, trigger)
        })
      {
        Error(Nil) -> Error(SyncBusy)
        Ok(result) -> result
      }
  }
}

fn do_sync(ctx: Context, trigger: String) -> Result(SyncReport, SyncError) {
  let started = clock.now_local()
  let #(begin_date, end_date) = config.order_window(ctx.config)
  let orders_result = collect_pages(ctx, begin_date, end_date, 0, [])
  let warehouses_result = case orders_result {
    Error(_) -> Error(SyncRejected(""))
    Ok(_) ->
      case session.with_client(ctx.session, ql361api.fetch_warehouses) {
        Ok(rows) -> Ok(rows)
        Error(err) -> Error(SyncQl361(err))
      }
  }

  case orders_result, warehouses_result {
    Error(err), _ -> {
      record_failure(ctx, trigger, started, sync_report.describe(err))
      Error(err)
    }
    _, Error(err) -> {
      record_failure(ctx, trigger, started, sync_report.describe(err))
      Error(err)
    }
    Ok(orders), Ok(warehouses) ->
      write_orders(ctx, trigger, started, begin_date, end_date, orders, warehouses)
  }
}

fn write_orders(
  ctx: Context,
  trigger: String,
  started: String,
  begin_date: String,
  end_date: String,
  orders: List(PurchaseOrderSummary),
  warehouses: List(ql361api.QlWarehouse),
) -> Result(SyncReport, SyncError) {
  let tokens = ctx.config.stock_warehouses
  let stock_ids = warehouse_scope.stock_warehouse_ids(warehouses, tokens)
  let orders =
    list.filter(orders, fn(order) {
      order.ifred == 0
      && warehouse_scope.bill_in_stock_scope(
        order.warehouse_id,
        order.warehouse_name,
        warehouses,
        tokens,
      )
    })
  let prepared =
    database.transaction(ctx.db, fn(conn) {
      list.try_map(orders, fn(order) {
        use stamp <- result.try(repo.get_inbound_stamp(conn, order.bill_id))
        Ok(#(order, stamp))
      })
    })

  use pairs <- result_db(prepared)
  let need =
    list.filter(pairs, fn(pair) { repo.needs_inbound_detail(pair.1, pair.0) })
    |> list.map(fn(pair) { pair.0.bill_id })
  let #(details, detail_errors) = fetch_details(ctx, need, [], [])
  let finished = clock.now_local()
  let status = case detail_errors {
    [] -> "ok"
    _ -> "error"
  }
  let error_message = case detail_errors {
    [] -> ""
    [err, ..] -> sync_report.describe(err)
  }
  let seen = list.map(orders, fn(order) { order.bill_id })
  let run =
    SyncRun(
      id: clock.now_local() <> "-" <> wisp.random_string(8),
      entity: "purchase_receipts",
      trigger: trigger,
      started_at: started,
      finished_at: finished,
      status: status,
      fetched: list.length(orders),
      upserted: list.length(orders),
      error_message: error_message,
    )

  let written =
    database.transaction(ctx.db, fn(conn) {
      use _ <- result.try(repo.delete_posted_receipts_absent(
        conn,
        begin_date,
        end_date,
        stock_ids,
        seen,
      ))
      use _ <- result.try(list.try_fold(orders, Nil, fn(_, order) {
        repo.upsert_inbound_summary(conn, order, 501, finished)
      }))
      use _ <- result.try(list.try_fold(details, Nil, fn(_, order) {
        repo.save_inbound_detail(conn, order, 501, finished)
      }))
      use _ <- result.try(repo.set_cursor(conn, "purchase_receipts", finished, finished))
      repo.insert_run(conn, run)
    })

  case written, detail_errors {
    Error(err), _ -> Error(SyncDb(err))
    Ok(_), [] ->
      Ok(SyncReport(
        entity: "purchase_receipts",
        trigger: trigger,
        fetched: list.length(orders),
        upserted: list.length(orders),
        cursor: finished,
      ))
    Ok(_), [err, ..] -> Error(err)
  }
}

fn collect_pages(
  ctx: Context,
  begin_date: String,
  end_date: String,
  page: Int,
  acc: List(PurchaseOrderSummary),
) -> Result(List(PurchaseOrderSummary), SyncError) {
  case page >= max_pages {
    True -> Error(SyncRejected("采购入库单分页超过 500 页,已中止"))
    False -> {
      let fetched =
        session.with_client(ctx.session, fn(client) {
          ql361api.fetch_purchase_receipt_page(
            client,
            begin_date,
            end_date,
            page,
            ql_purchases.page_size,
          )
        })
      case fetched {
        Error(err) -> Error(SyncQl361(err))
        Ok(page_data) -> {
          let acc = list.append(acc, page_data.items)
          let count = list.length(page_data.items)
          let finished =
            count == 0
            || count < ql_purchases.page_size
            || list.length(acc) >= page_data.total
          case finished {
            True -> Ok(acc)
            False -> collect_pages(ctx, begin_date, end_date, page + 1, acc)
          }
        }
      }
    }
  }
}

fn fetch_details(
  ctx: Context,
  bill_ids: List(Int),
  ok: List(PurchaseOrder),
  errors: List(SyncError),
) -> #(List(PurchaseOrder), List(SyncError)) {
  case bill_ids {
    [] -> #(list.reverse(ok), list.reverse(errors))
    [bill_id, ..rest] -> {
      let fetched =
        session.with_client(ctx.session, fn(client) {
          ql361api.fetch_purchase_receipt_detail(client, bill_id)
        })
      case fetched {
        Ok(order) -> fetch_details(ctx, rest, [order, ..ok], errors)
        Error(err) -> fetch_details(ctx, rest, ok, [SyncQl361(err), ..errors])
      }
    }
  }
}

fn record_failure(
  ctx: Context,
  trigger: String,
  started: String,
  message: String,
) -> Nil {
  let run =
    SyncRun(
      id: clock.now_local() <> "-" <> wisp.random_string(8),
      entity: "purchase_receipts",
      trigger: trigger,
      started_at: started,
      finished_at: clock.now_local(),
      status: "error",
      fetched: 0,
      upserted: 0,
      error_message: message,
    )
  let _ = database.transaction(ctx.db, fn(conn) { repo.insert_run(conn, run) })
  Nil
}

fn result_db(
  result: Result(a, database.DbError),
  next: fn(a) -> Result(b, SyncError),
) -> Result(b, SyncError) {
  case result {
    Ok(value) -> next(value)
    Error(err) -> Error(SyncDb(err))
  }
}

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.