电子物流中的EDI 应用

电子物流中的EDI 应用

背景

EDI 全称是Electronic data interchange, 即电子数据交换。在传统企业里,很多流程上的操作或者通信一般是由纸质媒介完成的,比如说采购订单、发票、订单同步之类的。但由于纸质媒介一切传播全靠人手,就会带来很多不可避免的缺点,比如说操作及同步信息慢、人力及物力资源消耗大等等。EDI 的出现就是为了解决纸质交互带来的缺点。它可以极大地提高业务效率、更快地同步各种状态及信息、减少纠错流程、接近实时地访问信息,最重要的是它可以省钱。有研究表明平均来说使用EDI 的成本相比纸质交换来说只需要纸质的三分之一开销。而且可以节省更多的时间(有研究是节省61%),缩短订单的周期,将流程自动化等等。

估计能看到这文章的人多为互联网从业者,可能很难想象在这个“信息化”的时代里还有那么多没有被“信息化”的地方。但神奇的是,在完全没有信息化的情况下,人们也靠人力实现了一切甚至直到这一刻还有那么多的传统行业在过着完全不一样的日常流程。

当你看到这第一段的时候,你可能会想:这不就是 “接口(API)” 做的事吗?EDI 成立之初其实做的就是类似我们现在用API 做的事。只是年代不同。EDI 最早在1960 年代就出现了。而我们更为熟悉一些的像XML 是1996 年才出现的,而JSON 是2013 年才有。现代互联网的历史还真就这么短。

话说回来,EDI 与API 的不同之处,在我的理解里,相比我们现在的API, EDI 的概念其实更像是通用的API. 我们API 的“标准” 更多的是大家用相同的技术,但各家有各家的定义。而EDI 是大家用相同的技术,并且大家用相同的结构。因为EDI 文件中并不包含解释。所以每一份EDI 里面用的字段,表达的内容都是一样的。你也可以理解为是一个没有包含字段名只有各个字段值的API.

一开始EDI 起源于军事物流。但随着时间的发展,各行各业慢慢都用上了这个东西,但又因为不同行业的需求不同,所以现今EDI 有多个流通的标准。比如说船舶舱单的用的EDI 856,发票用的EDI 810,美国最多使用的ANSI X12,全球其他地方使用比较多的UN/EDIFACT 等等。不同的行业、不同的情景都会有一个对应的标准可以用。

原理

那么说了那么多,如何使用EDI 呢?很简单,分三步:

  1. 准备需要传输的数据
  2. 把数据转换成EDI 格式
  3. 关上冰箱门(bushi

EDI 根据使用场景的不同,一般EDI 是一个文本文件。里面按照某种标准将对应的信息转化成EDI 格式保存。至于传输它,你可以用FTP/SFTP/FTPS, AS1/AS2/AS4, OFTP/OFTP2 甚至是用电子邮件发送这个EDI 文件都行。只要是能从一个地方传输一个文本文件到另外一个地方的都行。甚至有人是做成了API 的也有(格式用的EDI)。总之大部分情况下EDI 都是直接的端对端传输。但也有少量的VAN(增值网络)。

另外一端取到文件后,如果是自动化的可以用程序进行下一步流程。如果是要给人看的则可以用EDI 翻译噐或者用解析器去打开它们。

比如说我之前做的一个项目用的EDI 315,315主要用在船运中跟踪物流信息状态及运输/集装箱的一些事件详情等。它的内容如下:

ISA*00*          *00*          *ZZ*OECGROUP       *ZZ*AAA            *201120*1304*U*00401*000259937*0*P*>
GS*QO*OECGROUP*AAA*20201120*1304*259937*X*004010
ST*315*0001
B4***I*20201030*0000*CNYTN*DFSU*773057*L*4500*CNYTN*UN*6
N9*BM*OERT210702J01222
N9*BN*MEDUZ7825111
N9*EQ*DFSU7730576
N9*SN*FJ10356721
N9*SCA*OERT
Q2*NONE********045W***L*MAERSK ALGOL
R4*5*UN*CNYTN*YANTIAN PT*CN
DTM*140*20201030*0000*LT
R4*R*UN*CNYTN*YANTIAN PT*CN
DTM*140*20201030*0000*LT
R4*L*UN*CNYTN*YANTIAN PT*CN
DTM*140*20201110*1040*LT
R4*D*UN*USSAV*SAVANNAH*US
DTM*139*20201213*0000*LT
R4*E*UN*USSAV*SAVANNAH*US
DTM*139*20201215*1500*LT
SE*19*0001
GE*1*259937
IEA*1*000259937

这就是一个完整的EDI 文件。看着可能觉得像乱码,但其实只是它没有字段名解释而已。

所有的EDI 文件都是由三个块组成的:

  • Element:元素,一行里面的内容就是一个个不同的元素。
  • Segments:段,段可以理解为一些同类型的元素,类似于组的概念。对元素进行分类。像上面的例子中一行就是一个组。
  • Transaction Sets:事务集,也可以叫EDI 信息或者EDI 事务。当信息以段的形式收集好后它们就会组成集。

之所以要规定这些标准,就是要用一个经由双方认可的标准去传输信息可以用更少量的内容去表达更多的事。

下面我只解释上面例子中的一段,大概了解一下意思知道原理就行。更多详细的各段的代表的意思可以看这个文档了解:315 Status Details (Ocean)
上面这个文档只是我在网上找的,用的是同个标准但细节可能会和我这个例子不完全一样,因为同个标准下,不同的公司可能用到的字段不完全相同,比如有些字段它们公司不需要可能就给省略了。但意思是一个意思。

R4*5*UN*CNYTN*YANTIAN PT*CN
  • "R4" - 是这节的头,标识了这是一行描述 "Port or Terminal" 的相关内容。

  • ""* - 星号在这份文件中就是个分隔符,没有实际意思。所以这段本质上是:[R4, 5, UN, CNYTN, YANTIAN PT, CN] 这么几节信息。

  • "5" - 这也是一个约定的值,当它是 "5" 时,代表这在描述 "Active Location".

  • "UN" - 代表下个值是采用的UNLOCODE, 即港口码头代码表,描述全球各个港口用的一个表。

  • "CNYTN" - 这是UNLOCODE 的当前这个港口的代号。可以在这里查询。即深圳的盐田港

  • "YANTIAN PT" - 这个是港口的名字。PT是Port, 即港口。

  • "CN" - 这是两位的ISO 国家代码,指中国🇨🇳

在有说明的情况下其实挺好理解的。但是没有说明就会是天书。。

实施

关于这节其实我一直在想需不需要写。因为在原理清楚了之后其实就没有很大必要写了,是个开发都能整个解析出来的。而且Github 上也挺多现成的类库的,不过因为标准太多了很大机率你还是得自已实现一个自已需要的。

关于传输的,如果是像电子邮件的可以直接用邮件服务拦截附件并发送内容到解析程序。如果是ftp 之类的文件服务可能得设立一个文件变动的监控程序或者是弄个定时器定时扫描。

关于解析内容的,下面我附个最简单的解析代码解析上面这个例子吧。解析这种东西看使用场景需要可以变得很复杂也可以很简单。一切跟着需求走。

const UNUSED = undefined;

const InterchangeControlHeader = [
  'AuthorizationInformationQualifier',
  'AuthorizationInformation',
  'SecurityInformationQualifier',
  'SecurityInformation',
  'InterchangeSenderIDQualifier',
  'InterchangeSenderID',
  'InterchangeReceiverIDQualifier',
  'InterchangeReceiverID',
  'InterchangeDate',
  'InterchangeTime',
  'InterchangeControlStandardsIdentifier',
  'InterchangeControlVersionNumber',
  'InterchangeControlNumber',
  'AcknowledgmentRequested',
  'UsageIndicator',
  'ComponentElementSeparator',
];

const FunctionalGroupHeader = [
  'FunctionalIdentifierCode',
  'ApplicationSendersCode',
  'ApplicationReceiversCode',
  'Date',
  'Time',
  'GroupControlNumber',
  'ResponsibleAgencyCode',
  'VersionReleaseIndustryIdentifierCode',
];

const TransactionSetHeader = [
  'TransactionSetIdentifierCode',
  'TransactionSetControlNumber',
];

const BeginningSegmentForInquiryOrReply = [
  UNUSED,
  UNUSED,
  'ShipmentStatusCode',
  'Date',
  'StatusTime',
  'StatusLocation',
  'EquipmentInitial',
  'EquipmentNumber',
  'EquipmentStatusCode',
  'EquipmentType',
  'LocationIdentifier',
  'LocationQualifier',
  'EquipmentNumberCheckDigit',
];

const ReferenceIdentification = [
  'ReferenceIdentificationQualifier',
  'ReferenceIdentification',
];

const StatusDetailsOcean = [
  'VesselCode',
  UNUSED,
  UNUSED,
  UNUSED,
  UNUSED,
  UNUSED,
  UNUSED,
  'CountryCode',
  'VoyageNumber',
  UNUSED,
  UNUSED,
  'VesselCodeQualifier',
  'VesselName',
];

const PortOrTerminal = [
  'PortOrTerminalFunctionCode',
  'LocationQualifier',
  'LocationIdentifier',
  'PortName',
  'CountryCode',
];

const DateTimeReference = ['DateTimeQualifier', 'Date', 'Time', 'TimeCode'];

const TransactionSetTrailer = [
  'NumberOfIncludedSegments',
  'TransactionSetControlNumber',
];

const FunctionalGroupTrailer = [
  'NumberOfTransactionSetsIncluded',
  'GroupControlNumber',
];

const InterchangeControlTrailer = [
  'NumberOfIncludedFunctionalGroups',
  'InterchangeControlNumber',
];

const segments = {
  ISA: 'InterchangeControlHeader',
  GS: 'FunctionalGroupHeader',
  ST: 'TransactionSetHeader',
  B4: 'BeginningSegmentForInquiryOrReply',
  N9: 'ReferenceIdentification',
  Q2: 'StatusDetailsOcean',
  R4: 'PortOrTerminal',
  DTM: 'DateTimeReference',
  SE: 'TransactionSetTrailer',
  GE: 'FunctionalGroupTrailer',
  IEA: 'InterchangeControlTrailer',
};

const segmentFields = {
  [segments.ISA]: InterchangeControlHeader,
  [segments.GS]: FunctionalGroupHeader,
  [segments.ST]: TransactionSetHeader,
  [segments.B4]: BeginningSegmentForInquiryOrReply,
  [segments.N9]: ReferenceIdentification,
  [segments.Q2]: StatusDetailsOcean,
  [segments.R4]: PortOrTerminal,
  [segments.DTM]: DateTimeReference,
  [segments.SE]: TransactionSetTrailer,
  [segments.GE]: FunctionalGroupTrailer,
  [segments.IEA]: InterchangeControlTrailer,
};

type Edi315 = {
  [key in keyof typeof segments]?:
    | Record<Partial<keyof typeof segmentFields>, string>
    | Record<Partial<keyof typeof segmentFields>, string>[];
};

const parse = function(
  data: string | string[],
  segmentSeparator = '\n',
  valueSeparator = '*',
): Edi315 {
  const result = {};
  const availableSegments = Object.keys(segments);
  const _data = Array.isArray(data) ? data : data.split(segmentSeparator);

  _data.map((line, index) => {
    if (!line.replace(valueSeparator, '').trim()) return;

    const lineData = line.split(valueSeparator);
    const segmentName = lineData[0];

    if (!availableSegments.includes(segmentName)) {
      console.error('Unknown segment:', line);
      return;
    }

    lineData.slice(1).map((item, idx, array) => {
      const fieldName = segmentFields[segments[segmentName]][idx];

      if (result[segmentName] === undefined) {
        if (['N9', 'R4', 'DTM'].includes(segmentName)) {
          result[segmentName] = [];
        } else {
          result[segmentName] = {};
        }
      }
      if (segmentFields[segments[segmentName]].length != array.length) {
        if (idx < 1) {
          console.error('Mismatch segment length:', line);
        }
        return;
      }
      if (fieldName !== UNUSED) {
        if (Array.isArray(result[segmentName])) {
          if (result[segmentName][index] === undefined) {
            result[segmentName][index] = {};
          }
          result[segmentName][index][fieldName] = item;
        } else {
          result[segmentName][fieldName] = item;
        }
      }
    });
  });

  Object.keys(segments).map(segmentName => {
    if (Array.isArray(result[segmentName])) {
      result[segmentName] = result[segmentName].filter(
        x => x as Record<string, string>,
      );
    }
  });

  return result;
};

export default parse;

这样当传入上面这个例子时你就可以得到如下结果:

{
  ISA: {
    AuthorizationInformationQualifier: '00',
    AuthorizationInformation: '          ',
    SecurityInformationQualifier: '00',
    SecurityInformation: '          ',
    InterchangeSenderIDQualifier: 'ZZ',
    InterchangeSenderID: 'OECGROUP       ',
    InterchangeReceiverIDQualifier: 'ZZ',
    InterchangeReceiverID: 'AAA            ',
    InterchangeDate: '201120',
    InterchangeTime: '1304',
    InterchangeControlStandardsIdentifier: 'U',
    InterchangeControlVersionNumber: '00401',
    InterchangeControlNumber: '000259937',
    AcknowledgmentRequested: '0',
    UsageIndicator: 'P',
    ComponentElementSeparator: '>'
  },
  GS: {
    FunctionalIdentifierCode: 'QO',
    ApplicationSendersCode: 'OECGROUP',
    ApplicationReceiversCode: 'AAA',
    Date: '20201120',
    Time: '1304',
    GroupControlNumber: '259937',
    ResponsibleAgencyCode: 'X',
    VersionReleaseIndustryIdentifierCode: '004010'
  },
  ST: {
    TransactionSetIdentifierCode: '315',
    TransactionSetControlNumber: '0001'
  },
  B4: {
    ShipmentStatusCode: 'I',
    Date: '20201030',
    StatusTime: '0000',
    StatusLocation: 'CNYTN',
    EquipmentInitial: 'DFSU',
    EquipmentNumber: '773057',
    EquipmentStatusCode: 'L',
    EquipmentType: '4500',
    LocationIdentifier: 'CNYTN',
    LocationQualifier: 'UN',
    EquipmentNumberCheckDigit: '6'
  },
  N9: [
    {
      ReferenceIdentificationQualifier: 'BM',
      ReferenceIdentification: 'OERT210702J01222'
    },
    {
      ReferenceIdentificationQualifier: 'BN',
      ReferenceIdentification: 'MEDUZ7825111'
    },
    {
      ReferenceIdentificationQualifier: 'EQ',
      ReferenceIdentification: 'DFSU7730576'
    },
    {
      ReferenceIdentificationQualifier: 'SN',
      ReferenceIdentification: 'FJ10356721'
    },
    {
      ReferenceIdentificationQualifier: 'SCA',
      ReferenceIdentification: 'OERT'
    }
  ],
  Q2: {
    VesselCode: 'NONE',
    CountryCode: '',
    VoyageNumber: '045W',
    VesselCodeQualifier: 'L',
    VesselName: 'MAERSK ALGOL'
  },
  R4: [
    {
      PortOrTerminalFunctionCode: '5',
      LocationQualifier: 'UN',
      LocationIdentifier: 'CNYTN',
      PortName: 'YANTIAN PT',
      CountryCode: 'CN'
    },
    {
      PortOrTerminalFunctionCode: 'R',
      LocationQualifier: 'UN',
      LocationIdentifier: 'CNYTN',
      PortName: 'YANTIAN PT',
      CountryCode: 'CN'
    },
    {
      PortOrTerminalFunctionCode: 'L',
      LocationQualifier: 'UN',
      LocationIdentifier: 'CNYTN',
      PortName: 'YANTIAN PT',
      CountryCode: 'CN'
    },
    {
      PortOrTerminalFunctionCode: 'D',
      LocationQualifier: 'UN',
      LocationIdentifier: 'USSAV',
      PortName: 'SAVANNAH',
      CountryCode: 'US'
    },
    {
      PortOrTerminalFunctionCode: 'E',
      LocationQualifier: 'UN',
      LocationIdentifier: 'USSAV',
      PortName: 'SAVANNAH',
      CountryCode: 'US'
    }
  ],
  DTM: [
    {
      DateTimeQualifier: '140',
      Date: '20201030',
      Time: '0000',
      TimeCode: 'LT'
    },
    {
      DateTimeQualifier: '140',
      Date: '20201030',
      Time: '0000',
      TimeCode: 'LT'
    },
    {
      DateTimeQualifier: '140',
      Date: '20201110',
      Time: '1040',
      TimeCode: 'LT'
    },
    {
      DateTimeQualifier: '139',
      Date: '20201213',
      Time: '0000',
      TimeCode: 'LT'
    },
    {
      DateTimeQualifier: '139',
      Date: '20201215',
      Time: '1500',
      TimeCode: 'LT'
    }
  ],
  SE: {
    NumberOfIncludedSegments: '19',
    TransactionSetControlNumber: '0001'
  },
  GE: {
    NumberOfTransactionSetsIncluded: '1',
    GroupControlNumber: '259937'
  },
  IEA: {
    NumberOfIncludedFunctionalGroups: '1',
    InterchangeControlNumber: '000259937'
  }
}

在这个例子中我并没有对缩写类的词汇或者字段进行拓展,保留了它们在文件中的样子,实际上使用的话你可以把它们拓展成人眼可直接阅读的原意可能会更好点,再有就是对不同的类型的字段进行类型转换也是不错的,比如说日期的转成日期格式,数字的转成数字格式。总之解析是个可以不断丰富的过程,但我这没有做很多。

补充阅读:

  1. 维基百科 - EDI
  2. 中国的港口大全
  3. 实时的港口监测信息
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容