moon-lot-recall

    Offline food lot recall impact tracing and evidence review

    food-safety
    recall
    traceability
    lot
    audit
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    8

    #moon-lot-recall

    CI

    MoonBit 食品批次召回影响追踪库与离线命令行工具。 输入批次、加工、出货和召回线索,输出可能受影响的下游批次、收货方、证据路径、账面库存与待核查事项。适合食品厂、中央厨房、仓配团队把分散的 ERP 导出表快速整理成可审查的召回工作单。

    本项目分析的是可能暴露的范围。加工中的混料会保守地标记全部输出批次;路径不等于污染证据,账面库存不等于现场库存。工具不会自动发通知、判定食品安全或宣称法定召回完成。

    #30 秒试用

    安装 MoonBit 与 Node.js,然后在仓库根目录运行:

    moon update moon test --warn-list=-implicit_impl_as_method --deny-warn moon run cmd/main --target js --warn-list=-implicit_impl_as_method -- analyze examples/bakery-ledger.json examples/bakery-recall.json markdown moon run cmd/main --target js --warn-list=-implicit_impl_as_method -- actions examples/bakery-ledger.json examples/bakery-recall.json moon run cmd/main --target js --warn-list=-implicit_impl_as_method -- readiness examples/bakery-ledger.json examples/bakery-recall.json examples/bakery-counts.csv examples/bakery-responses.csv

    示例是虚构的面包店数据:FLOUR-A → DOUGH-A → BREAD-A,因此 OUT-101 和 OUT-102 进入收货方核查表;由 FLOUR-B 制成的 BREAD-B 不在范围内。现场盘点 BREAD-A 比账面少 50 克,OUT-102 仅有一次联系尝试,所以证据检查仍有未完成项。

    #数据与功能

    能力输入输出及用途
    校验批次、加工、出货记录重复 ID、未知引用、时间倒置、质量不守恒、超额分配等诊断;无效数据拒绝分析
    下游追踪召回种子批次、截点受影响批次、出货、从种子到输出的加工路径
    多来源归因多个可疑种子批次每个输出批次对应的所有可达种子及路径
    上游追踪目标批次、截点生产链上的原料来源和路径
    库存与行动同一账本、召回请求账面可控数量、待核查出货和人工处理清单
    盘点对账现场盘点 CSV账面与实测差额、缺失盘点、盘点凭证
    收货方合并受影响出货每个收货方一份待复核清单,避免重复联系
    联系进度人工填写的联系事件 CSV待联系、已尝试、已联系、已确认和失败的证据履历
    截点差异原截点、新截点新增受影响批次、出货,便于更新范围
    证据清单盘点与联系记录未盘点、库存差额、未确认出货等未完成项

    CLI 支持 JSON、Markdown 和面向人工表格的 CSV 导出。CSV 中可能被表格软件解释成公式的文本会加前导单引号。

    #文件格式

    • examples/bakery-ledger.json:schema_version: 1;lots、transformations、shipments。批次 ID 在账本内唯一。数量是整数克,时间是 Unix 秒。JSON 的 Int64 字段使用字符串表示,以免 JavaScript 丢精度。
    • examples/bakery-recall.json:id、reason、seed_lots、as_of。
    • examples/lots.csv、transformations.csv、shipments.csv:对应三张 ERP 导出表。加工表每行一个输入或输出分量,direction 为 input 或 output。
    • examples/bakery-counts.csv:lot_id,facility,counted_g,counted_at,reference。盘点时间必须等于分析截点;缺失批次会显式列出。
    • examples/bakery-responses.csv:shipment_id,status,recorded_at,reference,note。按文件顺序记录状态变化;acknowledged 必须先有 reached。

    #命令

    以下省略通用前缀 moon run cmd/main --target js --warn-list=-implicit_impl_as_method --:

    validate LEDGER.json analyze LEDGER.json RECALL.json [json|markdown|csv] actions LEDGER.json RECALL.json inventory LEDGER.json AS_OF upstream LEDGER.json LOT_ID AS_OF import-csv LOTS.csv TRANSFORMATIONS.csv SHIPMENTS.csv OUT.json reconcile LEDGER.json COUNTS.csv AS_OF [json|markdown] consignees LEDGER.json RECALL.json [json|markdown|csv] responses LEDGER.json RECALL.json EVENTS.csv [json|markdown] delta LEDGER.json RECALL.json NEW_AS_OF [json|markdown] readiness LEDGER.json RECALL.json COUNTS.csv EVENTS.csv [json|markdown]

    CLI 遇到无效记录返回非零退出码,适合放入数据导入流程。import-csv 只在整份账本通过校验后写出 JSON。所有分析在本机运行;示例没有连接食品监管数据库或客户通讯系统。

    #业务边界

    一笔加工事件把全部输入连接到全部输出,用于混料、分装等保守影响追踪。模型把一个批次放在一个设施;跨设施调拨需要在源系统转换成收发记录后导入,当前版本不会臆造转运。出货指向外部收货方,不代表收货方库存、销售量或消费者范围。若企业没有完整批次编码、加工记录或出货记录,报告无法覆盖缺失链路,必须先补齐来源数据。

    ReadinessReport.evidence_review_complete 只表示本工具清单中的盘点差额已消除且出货有确认记录。它不是法律合规或食品安全结论。真实召回仍需质量负责人核实记录、现场封存、依法通知和监管沟通。

    #开发与验证

    moon fmt moon info moon check --target js --warn-list=-implicit_impl_as_method --deny-warn moon test --warn-list=-implicit_impl_as_method --deny-warn

    测试覆盖多步加工、混料、无关批次、时间截点、CSV 转义与非法数据拒绝、盘点差额、收货方合并、联系状态转换、增量报告和证据清单。仓库的 .github/workflows/ci.yml 在推送和 PR 时运行同样的检查并执行 CLI 示例。

    #原创与参考

    本项目为原创 MoonBit 实现,不移植现有代码。数据交换与可追溯理念参考 GS1 EPCIS 2.0,食品召回场景参考 FDA 食品召回开放数据。当前版本没有声称兼容完整 EPCIS 事件格式,也没有把 FDA 数据当成企业内部批次事实。源代码采用 Apache License 2.0。

    ActionPlan

    pub(all) struct ActionPlan {
    recall_id : String
    as_of : Int64
    holds : Array[HoldAction]
    contacts : Array[ContactAction]
    estimated_hold_g : Int64
    shipped_for_review_g : Int64
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    AffectedLot

    pub(all) struct AffectedLot {
    lot_id : String
    product : String
    facility : String
    quantity_g : Int64
    classification : String
    path : Array[ExposureStep]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Classification is seed or potential. Neither proves contamination.

    AffectedShipment

    pub(all) struct AffectedShipment {
    shipment_id : String
    lot_id : String
    consignee : String
    quantity_g : Int64
    shipped_at : Int64
    reference : String
    path : Array[ExposureStep]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    AttributionReport

    pub(all) struct AttributionReport {
    recall_id : String
    as_of : Int64
    lots : Array[AttributionRow]
    shipments : Array[ShipmentAttribution]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    AttributionRow

    pub(all) struct AttributionRow {
    lot_id : String
    product : String
    sources : Array[SourcePath]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    ConsigneePacket

    pub(all) struct ConsigneePacket {
    consignee : String
    shipment_ids : Array[String]
    lot_ids : Array[String]
    references : Array[String]
    total_quantity_g : Int64
    earliest_shipment_at : Int64
    latest_shipment_at : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A communication packet groups all affected shipments for one recipient. It is a draft for human review, never an automatically sent notice.

    ConsigneeReport

    pub(all) struct ConsigneeReport {
    recall_id : String
    as_of : Int64
    packets : Array[ConsigneePacket]
    shipment_count : Int
    total_quantity_g : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    ContactAction

    pub(all) struct ContactAction {
    shipment_id : String
    consignee : String
    reference : String
    quantity_g : Int64
    lot_id : String
    path : Array[ExposureStep]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    CsvRecord

    pub(all) struct CsvRecord {
    line : Int
    cells : Array[String]
    } derive(
    Debug
    )

    CsvTable

    pub(all) struct CsvTable {
    headers : Array[String]
    rows : Array[CsvRecord]
    } derive(
    Debug
    )

    CsvTable::column

    fn CsvTable::column(self : CsvTable, name : String) -> Int?

    CsvTable::has_exact_headers

    fn CsvTable::has_exact_headers(self : CsvTable, expected : Array[String]) -> Bool

    Diagnostic

    pub(all) struct Diagnostic {
    severity : String
    code : String
    entity : String
    message : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Error and warning codes are stable for CI and downstream applications.

    ExposureStep

    pub(all) struct ExposureStep {
    from_lot : String
    transformation_id : String
    to_lot : String
    occurred_at : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    One processing step from an affected input to an affected output.

    HoldAction

    pub(all) struct HoldAction {
    lot_id : String
    facility : String
    product : String
    estimated_on_hand_g : Int64
    classification : String
    path : Array[ExposureStep]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    ImpactDelta

    pub(all) struct ImpactDelta {
    recall_id : String
    previous_as_of : Int64
    current_as_of : Int64
    previous_lot_count : Int
    current_lot_count : Int
    previous_shipment_count : Int
    current_shipment_count : Int
    new_lots : Array[AffectedLot]
    new_shipments : Array[AffectedShipment]
    newly_shipped_g : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A new analysis at a later cutoff can reveal more processed lots and shipments. Existing rows remain visible in the earlier report.

    InventoryReport

    pub(all) struct InventoryReport {
    as_of : Int64
    rows : Array[InventoryRow]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    InventoryReport::for_lot

    fn InventoryReport::for_lot(self : InventoryReport, id : String) -> InventoryRow?

    InventoryRow

    pub(all) struct InventoryRow {
    lot_id : String
    product : String
    facility : String
    produced_g : Int64
    processed_g : Int64
    shipped_g : Int64
    on_hand_g : Int64
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    Ledger

    pub(all) struct Ledger {
    schema_version : Int
    lots : Array[Lot]
    transformations : Array[Transformation]
    shipments : Array[Shipment]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    The document contains only operational facts. Recall decisions live in a separate request so the same ledger can be queried repeatedly.

    Lot

    pub(all) struct Lot {
    id : String
    product : String
    facility : String
    quantity_g : Int64
    produced_at : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A lot represents one physical stock batch at one facility. Quantities use integer grams, avoiding rounding during the recall traversal.

    Portion

    pub(all) struct Portion {
    lot_id : String
    quantity_g : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    One quantity of a lot consumed or produced in a processing event.

    ReadinessItem

    pub(all) struct ReadinessItem {
    category : String
    entity_id : String
    detail : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    One unresolved item that needs human investigation or recorded evidence.

    ReadinessReport

    pub(all) struct ReadinessReport {
    recall_id : String
    as_of : Int64
    affected_lot_count : Int
    affected_shipment_count : Int
    physical_count_count : Int
    acknowledged_shipment_count : Int
    items : Array[ReadinessItem]
    evidence_review_complete : Bool
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    RecallReport

    pub(all) struct RecallReport {
    recall_id : String
    as_of : Int64
    affected_lots : Array[AffectedLot]
    affected_shipments : Array[AffectedShipment]
    diagnostics : Array[Diagnostic]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    This report is a conservative impact analysis, not a laboratory finding.

    RecallRequest

    pub(all) struct RecallRequest {
    id : String
    reason : String
    seed_lots : Array[String]
    as_of : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A recall request starts from known suspect lots, at an explicit cutoff.

    ReconciliationReport

    pub(all) struct ReconciliationReport {
    as_of : Int64
    rows : Array[ReconciliationRow]
    missing_counts : Array[String]
    total_expected_g : Int64
    total_counted_g : Int64
    balanced : Bool
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    ReconciliationRow

    pub(all) struct ReconciliationRow {
    lot_id : String
    facility : String
    expected_g : Int64
    counted_g : Int64
    difference_g : Int64
    status : String
    count_reference : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    ResponseEvent

    pub(all) struct ResponseEvent {
    shipment_id : String
    status : String
    recorded_at : Int64
    reference : String
    note : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A contact log event is entered by an operator after a real outreach step. It records no email or phone number and does not dispatch a message.

    ResponseReport

    pub(all) struct ResponseReport {
    recall_id : String
    rows : Array[ResponseRow]
    pending_count : Int
    attempted_count : Int
    reached_count : Int
    acknowledged_count : Int
    failed_count : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    ResponseRow

    pub(all) struct ResponseRow {
    shipment_id : String
    consignee : String
    lot_id : String
    quantity_g : Int64
    status : String
    history : Array[ResponseEvent]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Shipment

    pub(all) struct Shipment {
    id : String
    lot_id : String
    quantity_g : Int64
    consignee : String
    shipped_at : Int64
    reference : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A shipment sends some of a finished lot to an external consignee.

    ShipmentAttribution

    pub(all) struct ShipmentAttribution {
    shipment_id : String
    lot_id : String
    consignee : String
    sources : Array[SourcePath]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    SourcePath

    pub(all) struct SourcePath {
    seed_lot : String
    path : Array[ExposureStep]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    StockCount

    pub(all) struct StockCount {
    lot_id : String
    facility : String
    counted_g : Int64
    counted_at : Int64
    reference : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A signed-off stock observation made at a physical location. The operator supplies the count; this library never invents a measured quantity.

    Transformation

    pub(all) struct Transformation {
    id : String
    facility : String
    occurred_at : Int64
    inputs : Array[Portion]
    outputs : Array[Portion]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A transformation links ingredient lots to output lots. Every output is conservatively considered exposed when any input lot is recalled.

    UpstreamLot

    pub(all) struct UpstreamLot {
    lot_id : String
    product : String
    facility : String
    quantity_g : Int64
    is_source : Bool
    path_to_target : Array[ExposureStep]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    UpstreamReport

    pub(all) struct UpstreamReport {
    target_lot : String
    as_of : Int64
    lots : Array[UpstreamLot]
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    ValidationReport

    pub(all) struct ValidationReport {
    valid : Bool
    diagnostics : Array[Diagnostic]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    analyze

    fn analyze(ledger : Ledger, request : RecallRequest) -> Result[RecallReport, ValidationReport]

    Trace possible downstream exposure and list shipments at or before the cutoff. Every affected shipment has a reproducible transformation path. The algorithm deliberately does not estimate which grams are contaminated in a mixed output. quantity_g is the upper bound requiring review.

    attribute_sources

    fn attribute_sources(ledger : Ledger, request : RecallRequest) -> Result[AttributionReport, ValidationReport]

    Show which suspect input lots can reach each downstream lot. Each seed gets one shortest processing chain; an output may have several seed sources. This prevents a single representative path from hiding another suspected ingredient in the same product.

    compare_cutoffs

    fn compare_cutoffs(ledger : Ledger, request : RecallRequest, current_as_of : Int64) -> Result[ImpactDelta, ValidationReport]

    A cutoff comparison is monotone for one immutable ledger. This function never treats a previously affected shipment as resolved or safe.

    decode_ledger

    fn decode_ledger(text : String) -> Result[Ledger, Diagnostic]

    Decode a versioned ledger. Duplicate JSON object keys are rejected rather than silently letting the last value win.

    decode_request

    fn decode_request(text : String) -> Result[RecallRequest, Diagnostic]

    encode_ledger

    fn encode_ledger(ledger : Ledger) -> String

    encode_report

    fn encode_report(report : RecallReport) -> String

    encode_request

    fn encode_request(request : RecallRequest) -> String

    encode_validation

    fn encode_validation(report : ValidationReport) -> String

    group_consignees

    fn group_consignees(ledger : Ledger, request : RecallRequest) -> Result[ConsigneeReport, ValidationReport]

    Produce one packet per consignee in first-shipment order. A repeated lot or invoice is listed once, while the quantity sums every distinct shipment.

    import_csv

    fn import_csv(lots_csv : String, transformations_csv : String, shipments_csv : String) -> Result[Ledger, ValidationReport]

    Import three small operational CSV tables into the same typed ledger used by the JSON API. Every numeric value remains an exact integer.

    inventory_at

    fn inventory_at(ledger : Ledger, as_of : Int64) -> Result[InventoryReport, ValidationReport]

    Reconstruct in-scope on-hand stock from immutable lot and event records. This is a ledger calculation, not a sensor or warehouse count. Differences from physical stock must be reconciled by the operator before action.

    parse_csv

    fn parse_csv(text : String) -> Result[CsvTable, Diagnostic]

    Parse RFC 4180-style CSV with doubled quotes and quoted newlines. CRLF and CR are normalized to LF. A malformed quoted field is rejected with a row number rather than silently shifting columns.

    parse_response_events_csv

    fn parse_response_events_csv(text : String) -> Result[Array[ResponseEvent], ValidationReport]

    parse_stock_counts_csv

    fn parse_stock_counts_csv(text : String) -> Result[Array[StockCount], ValidationReport]

    The CSV is deliberately strict: a wrong facility or duplicate lot could otherwise make a shortage look like an exact count.

    plan_actions

    fn plan_actions(ledger : Ledger, request : RecallRequest) -> Result[ActionPlan, ValidationReport]

    Generate review tasks only after the ledger and recall request pass all checks. holds are estimated from recorded events; a person must reconcile actual stock and determine legal recall actions.

    reconcile_stock

    fn reconcile_stock(ledger : Ledger, counts : Array[StockCount], as_of : Int64) -> Result[ReconciliationReport, ValidationReport]

    Compare a full physical count against reconstructed book stock at the cutoff. Missing rows remain explicit; they never count as zero stock.

    render_actions_markdown

    fn render_actions_markdown(plan : ActionPlan) -> String

    render_consignees_csv

    fn render_consignees_csv(report : ConsigneeReport) -> String

    A quoted CSV can be joined to the operator's own contact directory.

    render_consignees_markdown

    fn render_consignees_markdown(report : ConsigneeReport) -> String

    render_delta_markdown

    fn render_delta_markdown(delta : ImpactDelta) -> String

    render_markdown

    fn render_markdown(report : RecallReport) -> String

    A human-readable review report. The conservative language is deliberate: graph reachability establishes possible exposure, not contamination.

    render_readiness_markdown

    fn render_readiness_markdown(report : ReadinessReport) -> String

    render_reconciliation_markdown

    fn render_reconciliation_markdown(report : ReconciliationReport) -> String

    render_responses_markdown

    fn render_responses_markdown(report : ResponseReport) -> String

    render_shipments_csv

    fn render_shipments_csv(report : RecallReport) -> String

    render_upstream_markdown

    fn render_upstream_markdown(report : UpstreamReport) -> String

    render_validation

    fn render_validation(report : ValidationReport) -> String

    review_readiness

    fn review_readiness(ledger : Ledger, request : RecallRequest, counts : Array[StockCount], events : Array[ResponseEvent]) -> Result[ReadinessReport, ValidationReport]

    Combine physical count and outreach evidence for a single recall. This intentionally makes no claim that product is safe, that a legal recall is complete, or that a recipient actually quarantined stock.

    review_responses

    fn review_responses(ledger : Ledger, request : RecallRequest, events : Array[ResponseEvent]) -> Result[ResponseReport, ValidationReport]

    Replay events in file order for each affected shipment. Acknowledgement requires a prior reached event; all timestamps must be monotonic per shipment. Unknown shipment IDs and future ledger shipments fail closed.

    trace_upstream

    fn trace_upstream(ledger : Ledger, target_lot : String, as_of : Int64) -> Result[UpstreamReport, ValidationReport]

    Find all ingredient lots that can contribute to a finished lot. This is useful when investigators start from a suspect finished product and need to identify upstream supplier batches. Paths show one shortest processing chain; the lots array includes every reached ancestor.

    validate_ledger

    fn validate_ledger(ledger : Ledger) -> ValidationReport

    Validate structure, references, mass accounting, and chronology before interpreting any recall path. A rejected ledger never produces an impact report, so malformed evidence cannot look like a clean result.

    validate_request

    fn validate_request(ledger : Ledger, request : RecallRequest) -> ValidationReport

    Request checks include existence and cutoff consistency. The ledger is independently validated by analyze before this function is used.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io