diff --git a/cdrc/csv.go b/cdrc/csv.go index 45a347fc5..96b5d532b 100644 --- a/cdrc/csv.go +++ b/cdrc/csv.go @@ -106,9 +106,9 @@ func (self *CsvRecordsProcessor) processRecord(record []string) ([]*engine.CDR, recordCdrs := make([]*engine.CDR, 0) // More CDRs based on the number of filters and field templates for _, cdrcCfg := range self.cdrcCfgs { // cdrFields coming from more templates will produce individual storCdr records // Make sure filters are matching - passes := true - if len(cdrcCfg.Filters) == 0 { - for _, rsrFilter := range cdrcCfg.CdrFilter { + if len(cdrcCfg.Filters) == 0 { //backward compatibility + passes := true + for _, rsrFilter := range cdrcCfg.CdrFilter { // here process old filter for entire CDR if rsrFilter == nil { // Nil filter does not need to match anything continue } @@ -157,20 +157,32 @@ func (self *CsvRecordsProcessor) recordToStoredCdr(record []string, cdrcCfg *con var err error var lazyHttpFields []*config.CfgCdrField for _, cdrFldCfg := range cdrcCfg.ContentFields { - passes := true - for _, rsrFilter := range cdrFldCfg.FieldFilter { - if rsrFilter == nil { // Nil filter does not need to match anything + if len(cdrFldCfg.Filters) == 0 { //backward compatibility + passes := true + for _, rsrFilter := range cdrFldCfg.FieldFilter { // here process old filter for a field from template + if rsrFilter == nil { // Nil filter does not need to match anything + continue + } + if cfgFieldIdx, _ := strconv.Atoi(rsrFilter.Id); len(record) <= cfgFieldIdx { + return nil, fmt.Errorf("Ignoring record: %v - cannot compile field filter %+v", record, rsrFilter) + } else if _, err := rsrFilter.Parse(record[cfgFieldIdx]); err != nil { + passes = false + break + } + } + if !passes { // Stop processing this field template since it's filters are not matching continue } - if cfgFieldIdx, _ := strconv.Atoi(rsrFilter.Id); len(record) <= cfgFieldIdx { - return nil, fmt.Errorf("Ignoring record: %v - cannot compile field filter %+v", record, rsrFilter) - } else if _, err := rsrFilter.Parse(record[cfgFieldIdx]); err != nil { - passes = false - break + } else { + csvProvider, _ := newCsvProvider(record) + tenant, err := cdrcCfg.Tenant.ParseValue("") + if err != nil { + return nil, err + } + if pass, err := self.filterS.Pass(tenant, + cdrcCfg.Filters, csvProvider); err != nil || !pass { + continue // Not passes filters, ignore this CDR } - } - if !passes { // Stop processing this field template since it's filters are not matching - continue } if utils.IsSliceMember([]string{utils.KAM_FLATSTORE, utils.OSIPS_FLATSTORE}, self.dfltCdrcCfg.CdrFormat) { // Hardcode some values in case of flatstore switch cdrFldCfg.FieldId { @@ -179,7 +191,6 @@ func (self *CsvRecordsProcessor) recordToStoredCdr(record []string, cdrcCfg *con case utils.Usage: cdrFldCfg.Value = utils.ParseRSRFieldsMustCompile(strconv.Itoa(len(record)-1), utils.INFIELD_SEP) // in case of flatstore, last element will be the duration computed by us } - } var fieldVal string switch cdrFldCfg.Type { diff --git a/cdrc/fwv.go b/cdrc/fwv.go index 659311044..933aa67b9 100644 --- a/cdrc/fwv.go +++ b/cdrc/fwv.go @@ -192,6 +192,22 @@ func (self *FwvRecordsProcessor) recordToStoredCdr(record string, cdrcCfg *confi duMultiplyFactor = cdrcCfg.DataUsageMultiplyFactor } for _, cdrFldCfg := range cfgFields { + // this part need to be added for fwv to filter a field from template + // if len(cdrcCfg.Filters) == 0 { + // if passes := self.recordPassesCfgFilter(record, cdrFldCfg); !passes { + // continue + // } + // } else { + // fwvProvider, _ := newfwvProvider(record) + // tenant, err := cdrcCfg.Tenant.ParseValue("") + // if err != nil { + // return nil, err + // } + // if pass, err := self.filterS.Pass(tenant, + // cdrcCfg.Filters, fwvProvider); err != nil || !pass { + // continue // Not passes filters, ignore this CDR + // } + // } var fieldVal string switch cdrFldCfg.Type { case utils.META_COMPOSED: diff --git a/cdrc/fwv_it_test.go b/cdrc/fwv_it_test.go index e2e8a1663..d2e3760b4 100644 --- a/cdrc/fwv_it_test.go +++ b/cdrc/fwv_it_test.go @@ -31,6 +31,7 @@ import ( "github.com/cgrates/cgrates/config" "github.com/cgrates/cgrates/engine" + "github.com/cgrates/cgrates/utils" ) var fwvCfgPath string @@ -147,3 +148,112 @@ func TestFwvitProcessFiles(t *testing.T) { t.Errorf("In CdrcOutDir, expecting 1 files, got: %d", len(filesOutDir)) } } + +func TestFwvitAnalyseCDRs(t *testing.T) { + var reply []*engine.ExternalCDR + if err := fwvRpc.Call("ApierV2.GetCdrs", utils.RPCCDRsFilter{}, &reply); err != nil { + t.Error("Unexpected error: ", err.Error()) + } else if len(reply) != 8 { + t.Error("Unexpected number of CDRs returned: ", len(reply)) + } + if err := fwvRpc.Call("ApierV2.GetCdrs", utils.RPCCDRsFilter{OriginIDs: []string{"CDR0000010"}}, &reply); err != nil { + t.Error("Unexpected error: ", err.Error()) + } else if len(reply) != 2 { + t.Error("Unexpected number of CDRs returned: ", len(reply)) + } +} + +func TestFwvitKillEngine(t *testing.T) { + if err := engine.KillEngine(*waitRater); err != nil { + t.Error(err) + } +} + +// Begin tests for cdrc fwv with new filters +func TestFwvit2InitCfg(t *testing.T) { + var err error + fwvCfgPath = path.Join(*dataDir, "conf", "samples", "cdrcfwvwithfilter") + if fwvCfg, err = config.NewCGRConfigFromFolder(fwvCfgPath); err != nil { + t.Fatal("Got config error: ", err.Error()) + } +} + +// Creates cdr files and moves them into processing folder +func TestFwvit2CreateCdrFiles(t *testing.T) { + if fwvCfg == nil { + t.Fatal("Empty default cdrc configuration") + } + for _, cdrcCfg := range fwvCfg.CdrcProfiles["/tmp/cgr_fwv/cdrc/in"] { + if cdrcCfg.ID == "FWVWithFilter" { + fwvCdrcCfg = cdrcCfg + } + } + if err := os.RemoveAll(fwvCdrcCfg.CdrInDir); err != nil { + t.Fatal("Error removing folder: ", fwvCdrcCfg.CdrInDir, err) + } + if err := os.MkdirAll(fwvCdrcCfg.CdrInDir, 0755); err != nil { + t.Fatal("Error creating folder: ", fwvCdrcCfg.CdrInDir, err) + } + if err := os.RemoveAll(fwvCdrcCfg.CdrOutDir); err != nil { + t.Fatal("Error removing folder: ", fwvCdrcCfg.CdrOutDir, err) + } + if err := os.MkdirAll(fwvCdrcCfg.CdrOutDir, 0755); err != nil { + t.Fatal("Error creating folder: ", fwvCdrcCfg.CdrOutDir, err) + } +} + +func TestFwvit2StartEngine(t *testing.T) { + if _, err := engine.StopStartEngine(fwvCfgPath, *waitRater); err != nil { + t.Fatal(err) + } +} + +// Connect rpc client to rater +func TestFwvit2RpcConn(t *testing.T) { + var err error + fwvRpc, err = jsonrpc.Dial("tcp", fwvCfg.RPCJSONListen) // We connect over JSON so we can also troubleshoot if needed + if err != nil { + t.Fatal("Could not connect to rater: ", err.Error()) + } +} + +// InitDb so we can rely on count +func TestFwvit2InitCdrDb(t *testing.T) { + if err := engine.InitStorDb(fwvCfg); err != nil { + t.Fatal(err) + } +} + +func TestFwvit2ProcessFiles(t *testing.T) { + fileName := "test1.fwv" + if err := ioutil.WriteFile(path.Join("/tmp", fileName), []byte(FW_CDR_FILE1), 0644); err != nil { + t.Fatal(err.Error()) + } + if err := os.Rename(path.Join("/tmp", fileName), path.Join(fwvCdrcCfg.CdrInDir, fileName)); err != nil { + t.Fatal(err) + } + time.Sleep(time.Duration(1) * time.Second) + filesInDir, _ := ioutil.ReadDir(fwvCdrcCfg.CdrInDir) + if len(filesInDir) != 0 { + t.Errorf("Files in cdrcInDir: %d", len(filesInDir)) + } + filesOutDir, _ := ioutil.ReadDir(fwvCdrcCfg.CdrOutDir) + if len(filesOutDir) != 1 { + t.Errorf("In CdrcOutDir, expecting 1 files, got: %d", len(filesOutDir)) + } +} + +func TestFwvit2AnalyseCDRs(t *testing.T) { + var reply []*engine.ExternalCDR + if err := fwvRpc.Call("ApierV2.GetCdrs", utils.RPCCDRsFilter{}, &reply); err != nil { + t.Error("Unexpected error: ", err.Error()) + } else if len(reply) != 2 { + t.Error("Unexpected number of CDRs returned: ", len(reply)) + } +} + +func TestFwvit2KillEngine(t *testing.T) { + if err := engine.KillEngine(*waitRater); err != nil { + t.Error(err) + } +} diff --git a/cdrc/fwv_test.go b/cdrc/fwv_test.go index 5d1e290e5..39a16b453 100644 --- a/cdrc/fwv_test.go +++ b/cdrc/fwv_test.go @@ -42,7 +42,6 @@ func TestFwvValue(t *testing.T) { } func TestFwvRecordPassesCfgFilter(t *testing.T) { - //record, configKey string) bool { cgrConfig, _ := config.NewDefaultCGRConfig() cdrcConfig := cgrConfig.CdrcProfiles["/var/spool/cgrates/cdrc/in"][0] // We don't really care that is for .csv since all we want to test are the filters cdrcConfig.CdrFilter = utils.ParseRSRFieldsMustCompile(`~52:s/^0(\d{9})/+49${1}/(^+49123123120)`, utils.INFIELD_SEP) diff --git a/cdrc/xml.go b/cdrc/xml.go index 49a961124..ea7602ef4 100644 --- a/cdrc/xml.go +++ b/cdrc/xml.go @@ -132,9 +132,9 @@ func (xmlProc *XMLRecordsProcessor) ProcessNextRecord() (cdrs []*engine.CDR, err cdrXML := xmlProc.cdrXmlElmts[xmlProc.procItems] xmlProc.procItems += 1 for _, cdrcCfg := range xmlProc.cdrcCfgs { - if len(cdrcCfg.Filters) == 0 { + if len(cdrcCfg.Filters) == 0 { //backward compatibility filtersPassing := true - for _, rsrFltr := range cdrcCfg.CdrFilter { + for _, rsrFltr := range cdrcCfg.CdrFilter { // here process old filter for entire CDR if rsrFltr == nil { continue // Pass } @@ -178,6 +178,34 @@ func (xmlProc *XMLRecordsProcessor) recordToCDR(xmlEntity tree.Res, cdrcCfg *con var err error fldVals := make(map[string]string) for _, cdrFldCfg := range cdrcCfg.ContentFields { + if len(cdrFldCfg.Filters) == 0 { //backward compatibility + filtersPassing := true + for _, rsrFltr := range cdrFldCfg.FieldFilter { // here process old filter for a field from template + if rsrFltr == nil { + continue // Pass + } + absolutePath := utils.ParseHierarchyPath(rsrFltr.Id, "") + relPath := utils.HierarchyPath(absolutePath[len(xmlProc.cdrPath)-1:]) // Need relative path to the xmlElmnt + fieldVal, _ := elementText(xmlEntity, relPath.AsString("/", true)) + if _, err := rsrFltr.Parse(fieldVal); err != nil { + filtersPassing = false + break + } + } + if !filtersPassing { + continue + } + } else { + xmlProvider, _ := newXmlProvider(xmlEntity, xmlProc.cdrPath) + tenant, err := cdrcCfg.Tenant.ParseValue("") + if err != nil { + return nil, err + } + if pass, err := xmlProc.filterS.Pass(tenant, + cdrcCfg.Filters, xmlProvider); err != nil || !pass { + continue // Not passes filters, ignore this CDR + } + } if cdrFldCfg.Type == utils.META_COMPOSED { for _, cfgFieldRSR := range cdrFldCfg.Value { if cfgFieldRSR.IsStatic() { diff --git a/data/conf/samples/cdrcfwv/cgrates.json b/data/conf/samples/cdrcfwv/cgrates.json index 20d9cde85..318f6cf63 100644 --- a/data/conf/samples/cdrcfwv/cgrates.json +++ b/data/conf/samples/cdrcfwv/cgrates.json @@ -40,27 +40,28 @@ "cdr_source_id": "cdrc", // free form field, tag identifying the source of the CDRs within CDRS database "cdr_filter": "", // filter CDR records to import "header_fields": [ - {"tag": "FileName", "cdr_field_id": "CdrFileName", "type": "cdrfield", "value": "95", "width": 40, "padding":"right"}, - {"tag": "FileSeqNr", "cdr_field_id": "FileSeqNr", "type": "cdrfield", "value": "135", "width": 6, "padding":"zeroleft"}, - {"tag": "AccId1", "cdr_field_id": "accid", "type": "cdrfield", "value": "135", "width": 6, "padding":"zeroleft"}, + {"tag": "FileName", "field_id": "CdrFileName", "type": "*composed", "value": "95", "width": 40, "padding":"right"}, + {"tag": "FileSeqNr", "field_id": "FileSeqNr", "type": "*composed", "value": "135", "width": 6, "padding":"zeroleft"}, + {"tag": "AccId1", "field_id": "AccId1", "type": "*composed", "value": "135", "width": 6, "padding":"zeroleft"}, ], "content_fields": [ // import template, tag will match internally CDR field, in case of .csv value will be represented by index of the field value - {"tag": "Tor", "cdr_field_id": "tor", "type": "cdrfield", "value": "^*voice", "mandatory": true}, - {"tag": "AccId1", "cdr_field_id": "accid", "type": "cdrfield", "value": "3", "width": 3, "padding":"zeroleft", "mandatory": true}, - {"tag": "AccId2", "cdr_field_id": "accid", "type": "cdrfield", "value": "14", "width": 16, "padding":"right", "mandatory": true}, - {"tag": "ReqType", "cdr_field_id": "reqtype", "type": "cdrfield", "value": "^rated", "mandatory": true}, - {"tag": "Direction", "cdr_field_id": "direction", "type": "cdrfield", "value": "^*out", "mandatory": true}, - {"tag": "Tenant", "cdr_field_id": "tenant", "type": "cdrfield", "value": "^cgrates.org", "mandatory": true}, - {"tag": "Category", "cdr_field_id": "category", "type": "cdrfield", "value": "^call", "mandatory": true}, - {"tag": "Account", "cdr_field_id": "account", "type": "cdrfield", "value": "30", "width": 19, "padding":"right", "mandatory": true}, - {"tag": "Subject", "cdr_field_id": "subject", "type": "cdrfield", "value": "30", "width": 19, "padding":"right", "mandatory": true}, - {"tag": "Destination", "cdr_field_id": "destination", "type": "cdrfield", "value": "52", "width": 28, "padding":"right", "mandatory": true}, - {"tag": "SetupTime", "cdr_field_id": "setup_time", "type": "cdrfield", "value": "~14:s/(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}-${2}-${3} ${4}:${5}:${6}/", "width": 16, "mandatory": true}, - {"tag": "AnswerTime", "cdr_field_id": "answer_time", "type": "cdrfield", "value": "~14:s/(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}-${2}-${3} ${4}:${5}:${6}/", "width": 16, "mandatory": true}, - {"tag": "Usage", "cdr_field_id": "usage", "type": "cdrfield", "value": "~127:s/(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}h${2}m${3}s/", "width": 8, "mandatory": true}, - {"tag": "DisconnectCause", "cdr_field_id": "disconnect_cause", "type": "cdrfield", "value": "138", "width": 1, "mandatory": true}, - {"tag": "RetailAmount", "cdr_field_id": "RetailAmount", "type": "cdrfield", "value": "203", "padding":"zeroleft", "width": 8}, - {"tag": "WholesaleAmount", "cdr_field_id": "RetailAmount", "type": "cdrfield", "value": "215", "padding":"zeroleft", "width": 8}, + {"tag": "Tor", "field_id": "ToR", "type": "*composed", "value": "^*voice", "mandatory": true}, + {"tag": "RequestType", "field_id": "RequestType", "type": "*composed", "value": "^rated", "mandatory": true}, + {"tag": "Direction", "field_id": "Direction", "type": "*composed", "value": "^*out", "mandatory": true}, + {"tag": "OriginID", "field_id": "OriginID", "type": "*composed", "value": "0", "width": 10, "padding":"right", "mandatory": true}, + {"tag": "Tenant", "field_id": "Tenant", "type": "*composed", "value": "^cgrates.org", "mandatory": true}, + {"tag": "Category", "field_id": "Category", "type": "*composed", "value": "^call", "mandatory": true}, + {"tag": "Account", "field_id": "Account", "type": "*composed", "value": "30", "width": 19, "padding":"right", "mandatory": true}, + {"tag": "Subject", "field_id": "Subject", "type": "*composed", "value": "30", "width": 19, "padding":"right", "mandatory": true}, + {"tag": "Destination", "field_id": "Destination", "type": "*composed", "value": "52", "width": 28, "padding":"right", "mandatory": true}, + {"tag": "SetupTime", "field_id": "SetupTime", "type": "*composed", "value": "~14:s/(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}-${2}-${3} ${4}:${5}:${6}/", "width": 16, "mandatory": true}, + {"tag": "AnswerTime", "field_id": "AnswerTime", "type": "*composed", "value": "~14:s/(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}-${2}-${3} ${4}:${5}:${6}/", "width": 16, "mandatory": true}, + {"tag": "Usage", "field_id": "Usage", "type": "*composed", "value": "~127:s/(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}h${2}m${3}s/", "width": 8, "mandatory": true}, + {"tag": "DisconnectCause", "field_id": "DisconnectCause", "type": "*composed", "value": "138", "width": 1, "mandatory": true}, + {"tag": "RetailAmount", "field_id": "RetailAmount", "type": "*composed", "value": "103", "padding":"zeroleft", "width": 8}, + {"tag": "WholesaleAmount", "field_id": "RetailAmount", "type": "*composed", "value": "115", "padding":"zeroleft", "width": 8}, + {"tag": "AccId1", "field_id": "AccId1", "type": "*composed", "value": "3", "width": 3, "padding":"zeroleft", "mandatory": true}, + {"tag": "AccId2", "field_id": "AccId2", "type": "*composed", "value": "14", "width": 16, "padding":"right", "mandatory": true}, ], "trailer_fields": [ {"tag": "NrOfCdrs", "type": "metatag", "metatag_id":"total_cdrs", "value": "142", "width": 8}, diff --git a/data/conf/samples/cdrcfwvwithfilter/cgrates.json b/data/conf/samples/cdrcfwvwithfilter/cgrates.json new file mode 100755 index 000000000..178d4ebfb --- /dev/null +++ b/data/conf/samples/cdrcfwvwithfilter/cgrates.json @@ -0,0 +1,74 @@ +{ + +// Real-time Charging System for Telecom & ISP environments +// Copyright (C) ITsysCOM GmbH +// +// This file contains the default configuration hardcoded into CGRateS. +// This is what you get when you load CGRateS with an empty configuration file. + + +"stor_db": { // database used to store offline tariff plans and CDRs + "db_password": "CGRateS.org", // password to use when connecting to stordb +}, + +"rals": { + "enabled": true, // enable Rater service: +}, + + +"scheduler": { + "enabled": true, // start Scheduler service: +}, + + +"cdrs": { + "enabled": true, // start the CDR Server service: +}, + + +"cdrc": [ + { + "id": "FWVWithFilter", + "enabled": true, // enable CDR client functionality + "dry_run": false, + "cdrs_conns": [ + {"address": "*internal"} // address where to reach CDR server. <*internal|x.y.z.y:1234> + ], + "cdr_format": "fwv", // CDR file format + "cdr_in_dir": "/tmp/cgr_fwv/cdrc/in", // absolute path towards the directory where the CDRs are stored + "cdr_out_dir": "/tmp/cgr_fwv/cdrc/out", // absolute path towards the directory where processed CDRs will be moved + "cdr_source_id": "cdrc", // free form field, tag identifying the source of the CDRs within CDRS database + "cdr_filter": "", // filter CDR records to import + "filters":["*string:0-10:CDR0000010"], + "header_fields": [ + {"tag": "FileName", "field_id": "CdrFileName", "type": "*composed", "value": "95", "width": 40, "padding":"right"}, + {"tag": "FileSeqNr", "field_id": "FileSeqNr", "type": "*composed", "value": "135", "width": 6, "padding":"zeroleft"}, + {"tag": "AccId1", "field_id": "AccId1", "type": "*composed", "value": "135", "width": 6, "padding":"zeroleft"}, + ], + "content_fields": [ // import template, tag will match internally CDR field, in case of .csv value will be represented by index of the field value + {"tag": "Tor", "field_id": "ToR", "type": "*composed", "value": "^*voice", "mandatory": true}, + {"tag": "RequestType", "field_id": "RequestType", "type": "*composed", "value": "^rated", "mandatory": true}, + {"tag": "Direction", "field_id": "Direction", "type": "*composed", "value": "^*out", "mandatory": true}, + {"tag": "OriginID", "field_id": "OriginID", "type": "*composed", "value": "0", "width": 10, "padding":"right", "mandatory": true}, + {"tag": "Tenant", "field_id": "Tenant", "type": "*composed", "value": "^cgrates.org", "mandatory": true}, + {"tag": "Category", "field_id": "Category", "type": "*composed", "value": "^call", "mandatory": true}, + {"tag": "Account", "field_id": "Account", "type": "*composed", "value": "30", "width": 19, "padding":"right", "mandatory": true}, + {"tag": "Subject", "field_id": "Subject", "type": "*composed", "value": "30", "width": 19, "padding":"right", "mandatory": true}, + {"tag": "Destination", "field_id": "Destination", "type": "*composed", "value": "52", "width": 28, "padding":"right", "mandatory": true}, + {"tag": "SetupTime", "field_id": "SetupTime", "type": "*composed", "value": "~14:s/(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}-${2}-${3} ${4}:${5}:${6}/", "width": 16, "mandatory": true}, + {"tag": "AnswerTime", "field_id": "AnswerTime", "type": "*composed", "value": "~14:s/(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}-${2}-${3} ${4}:${5}:${6}/", "width": 16, "mandatory": true}, + {"tag": "Usage", "field_id": "Usage", "type": "*composed", "value": "~127:s/(\\d{2})(\\d{2})(\\d{2})(\\d{2})/${1}h${2}m${3}s/", "width": 8, "mandatory": true}, + {"tag": "DisconnectCause", "field_id": "DisconnectCause", "type": "*composed", "value": "138", "width": 1, "mandatory": true}, + {"tag": "RetailAmount", "field_id": "RetailAmount", "type": "*composed", "value": "103", "padding":"zeroleft", "width": 8}, + {"tag": "WholesaleAmount", "field_id": "RetailAmount", "type": "*composed", "value": "115", "padding":"zeroleft", "width": 8}, + {"tag": "AccId1", "field_id": "AccId1", "type": "*composed", "value": "3", "width": 3, "padding":"zeroleft", "mandatory": true}, + {"tag": "AccId2", "field_id": "AccId2", "type": "*composed", "value": "14", "width": 16, "padding":"right", "mandatory": true}, + ], + "trailer_fields": [ + {"tag": "NrOfCdrs", "type": "metatag", "metatag_id":"total_cdrs", "value": "142", "width": 8}, + {"tag": "TotalDuration", "type": "metatag", "metatag_id":"total_duration", "value": "150", "width": 12}, + ], + }, +], + +}