电子物流中的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. 实时的港口监测信息

阿兵
7 声望2 粉丝