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 be9a71937c21f8685fdf6c7b7db46fd48970548067dd9120d48a987dcc2cd06a:


import app.{type Context}
import clock
import database
import gleam/list
import gleam/order
import gleam/result
import gleam/string
import ql361api.{type GoodsItem}
import ql361api/goods
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

pub type Mode {
  Incremental
  Full
}

const max_pages = 500

pub fn run(
  ctx: Context,
  trigger: String,
  mode: Mode,
) -> Result(SyncReport, SyncError) {
  case configured(ctx) {
    False -> Error(SyncNotConfigured)
    True ->
      case sync_lock.with_lock(ctx.lock, "products", fn() { do_sync(ctx, trigger, mode) }) {
        Error(Nil) -> Error(SyncBusy)
        Ok(result) -> result
      }
  }
}

fn do_sync(ctx: Context, trigger: String, mode: Mode) -> Result(SyncReport, SyncError) {
  let started = clock.now_local()
  use previous <- result_db(database.transaction(ctx.db, fn(conn) {
    repo.get_cursor(conn, "products")
  }))
  let modified_from = case mode {
    Full -> ""
    Incremental -> previous
  }
  let modified_to = clock.now_local()
  use items <- bind_sync(
    ctx,
    trigger,
    started,
    collect_pages(ctx, modified_from, modified_to, 0, []),
  )
  use warehouses <- bind_sync(
    ctx,
    trigger,
    started,
    case session.with_client(ctx.session, ql361api.fetch_warehouses) {
      Ok(rows) -> Ok(rows)
      Error(err) -> Error(SyncQl361(err))
    },
  )

  let products = list.filter(items, fn(item) { !item.is_category })
  let cursor = case mode {
    Full -> max_modified("", items)
    Incremental -> max_modified(previous, items)
  }
  let cursor = case cursor {
    "" -> previous
    value -> value
  }
  let finished = clock.now_local()
  let run =
    SyncRun(
      id: run_id(),
      entity: "products",
      trigger: trigger,
      started_at: started,
      finished_at: finished,
      status: "ok",
      fetched: list.length(items),
      upserted: list.length(products),
      error_message: "",
    )

  let written =
    database.transaction(ctx.db, fn(conn) {
      let stock_ids =
        warehouse_scope.stock_warehouse_ids(warehouses, ctx.config.stock_warehouses)
      use _ <- result.try(list.try_fold(warehouses, Nil, fn(_, warehouse) {
        repo.upsert_warehouse(
          conn,
          warehouse,
          list.contains(stock_ids, warehouse.id),
          finished,
        )
      }))
      use _ <- result.try(list.try_fold(products, Nil, fn(_, item) {
        repo.upsert_product(conn, item, finished)
      }))
      use _ <- result.try(repo.set_cursor(conn, "products", cursor, finished))
      repo.insert_run(conn, run)
    })

  case written {
    Error(err) -> {
      record_failure(ctx, trigger, started, database_message(err))
      Error(SyncDb(err))
    }
    Ok(_) ->
      Ok(SyncReport(
        entity: "products",
        trigger: trigger,
        fetched: list.length(items),
        upserted: list.length(products),
        cursor: cursor,
      ))
  }
}

fn collect_pages(
  ctx: Context,
  modified_from: String,
  modified_to: String,
  page: Int,
  acc: List(GoodsItem),
) -> Result(List(GoodsItem), SyncError) {
  case page >= max_pages {
    True -> Error(SyncRejected("商品分页超过 500 页,已中止且未推进游标"))
    False -> {
      let fetched =
        session.with_client(ctx.session, fn(client) {
          ql361api.fetch_goods_page(
            client,
            page,
            goods.page_size,
            modified_from,
            modified_to,
          )
        })
      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 < goods.page_size
            || list.length(acc) >= page_data.total
          case finished {
            True -> Ok(acc)
            False ->
              collect_pages(ctx, modified_from, modified_to, page + 1, acc)
          }
        }
      }
    }
  }
}

pub fn max_modified(current: String, items: List(GoodsItem)) -> String {
  list.fold(items, current, fn(acc, item) {
    case item.modified_at != "" && string.compare(item.modified_at, acc) == order.Gt {
      True -> item.modified_at
      False -> acc
    }
  })
}

fn record_failure(ctx: Context, trigger: String, started: String, message: String) -> Nil {
  let run =
    SyncRun(
      id: run_id(),
      entity: "products",
      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 configured(ctx: Context) -> Bool {
  ctx.config.ql361_username != "" && ctx.config.ql361_password != ""
}

fn run_id() -> String {
  clock.now_local() <> "-" <> wisp.random_string(8)
}

fn database_message(error: database.DbError) -> String {
  case error {
    database.DbError(message) -> message
  }
}

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))
  }
}

fn bind_sync(
  ctx: Context,
  trigger: String,
  started: String,
  result: Result(a, SyncError),
  next: fn(a) -> Result(b, SyncError),
) -> Result(b, SyncError) {
  case result {
    Ok(value) -> next(value)
    Error(err) -> {
      record_failure(ctx, trigger, started, sync_report.describe(err))
      Error(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.