后端基础入门 #

准备 #

创建Web项目 #

环境要求

使用Mall3s之前先确保安装了最新的.net 5 SDK并升级Visual Studio 2019至最新版。

创建ASP.NET Core Web 应用程序 #

  • 打开visual Studio 2019并创建Web项目

  • 配置项目名称

  • 选择webAPi项目

特别注意

Mall3s已经内置了Swagger规范化库,所以创建时无需勾选启动openAPi支持选项。否则提示版本不一致产生冲突。

脚手架 #

前端 #

共用前端,不需要脚手架,前端代码仓库地址:http://code.mall3s.com/erp/mall3s.web.git

后端 #

脚手架地址:http://code.mall3s.com/erp/mall3s.basedata/-/tree/new_version_220311

脚手架说明请查看项目README.md文档

注:如对脚手架改名,需把obj和bin文件夹删除(如有)。README.md文档一定要看!!!

初始化项目工程结构

  • Mall3s.Library

  • Mall3s.Entities实体层

  • Mall3s.Interface接口层

  • Mall3s.Serviceweb服务层

  • Mall3s.WebApi接口层

项目初始化**-Mall3s.API** #

Nacos环境配置 #

nacos环境地址:nacos.mall3s.com/nacos

  1. 新增业务数据库链接

nacos配置好数据库连接,在netcore-datasource.json****上新增配置

{
    "SkumsConnectionStrings": {
        "ConfigId": "mall3s_demo",//租户ID,通常使用数据库名不带环境,如mall3s_oms_test/mall3s_oms_pro,租户ID使用mall3s_oms
        "DBName": "mall3s_demo",//数据库名
        "DBType": "MySql",		//数据库类型
        "DefaultConnection": "server=sh-cdb-s261yedo.sql.tencentcdb.com;port=59347;uid=erpdba;pwd=erp123!@#;database={0}"//数据库链接
    }
}

备注:ConfigId为关键点,为了避免冲突,请使用数据库名不带环境,如mall3s_oms_test/mall3s_oms_pro,租户ID使用mall3s_oms,同一次启动服务,切勿同时注入正式和测试环境

  1. 新建业务项目公共nacos配置,netcore-项目名-common.json(如netcore-skums-common.json)
{
    "ExtraDB":["SkumsConnectionStrings"], //数据库配置
    "SkyWalking": {  
        "ServiceName": "mall3s-skums-api",//这里改为skywalking的服务名称
        "Namespace": "DEFAULT_GROUP"
    }
}

备注:修改数据库链接字符串以及skywalking的服务名

本地环境配置 #

  1. launchSettings.json环境变量添加skywalking配置:
  "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "SkyAPM.Agent.AspNetCore",
        "SKYWALKING__SERVICENAME": "mall3s-skums-api"
  }

备注:SKYWALKING__SERVICENAME改为本系统的服务名

  1. appsettings添加appsettings.json配置
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "NacosConfig": { //nacos配置中心,所有配置基于NacosConfig:GroupName命名空间
    "Listeners": [ //监听下面的配置(设置了的数据会自动加载到Configuration中),需要加载的配置文件请在此增加
      {
        "Optional": false, //代表是否自动刷新配置
        "DataId": "netcore-datasource.json", //代表资源ID
        "Group": "DEFAULT_GROUP" //资源的分组
      },
      {
        "Optional": false,
        "DataId": "netcore-common.json",
        "Group": "DEFAULT_GROUP"
      },
      {
        "Optional": false,
        "DataId": "netcore-app.json",
        "Group": "DEFAULT_GROUP"
      },
      {
        "Optional": false,
        "DataId": "netcore-skums-common.json",  //改为各自配置地址
        "Group": "DEFAULT_GROUP"
      }
     
    ],
    "UserName": "nacos",
    "Password": "erp1276",
    "DefaultTimeOut": 5000,
    "ListenInterval": 1000,
    "ServiceName": "mall3s-webapi-skums", //改为各自服务名
    "ClusterName": "DEFAULT",
    //"PreferredNetworks": "",
    "Weight": 100,
    "RegisterEnabled": true,
    "InstanceEnabled": true,
    "Ephemeral": true,
    "Secure": false,
    //"AccessKey": "",
    // "SecretKey": "",
    "ConfigUseRpc": false,
    "NamingUseRpc": false,
    "NamingLoadCacheAtStart": ""
  },
  //过滤api文档
  "AppSettings": {
    "HideApiDocList": ""
  },
  "xxlJob": {
    "appName": "xxl-job-executor-dotnet", //执行器(不需要改)
    "specialBindAddress": "",
    "port": 5000, //当前执行器
    "autoRegistry": true,
    "accessToken": "",
    "logRetentionDays": 30
  }
}

备注:注意新增的netcore-skums-common.json配置节点,以及修改ServiceName改为服务名mall3s-webapi-skums

  1. 添加开发环境配置 appsettings.Development.json
{
  "NacosConfig": { //nacos配置中心
    "ServerAddresses": [ "http://nacos.mall3s.com" ],
    "GroupName": "DEFAULT_GROUP",
    "Namespace": "69c4eecb-05bd-4041-81fe-1473f95f578c" //dev开发环境
    //"Ip": "localhost", //改成自己的服务ip,不填默认读取当前本机ip
    //"Port": 31201 //改成环境端口
  },
  "xxlJob": {
    "adminAddresses": "http://job.mall3s.com/xxl-job-admin" //正式环境改为线上:"adminAddresses": "http://xxl-job-admin.base:8080/xxl-job-admin",
  }
}
  1. 添加生产环境配置 appsettings.Product.json
{
  "NacosConfig": { //nacos
    "ServerAddresses": [ "http://nacos.base:30099" ],
    "GroupName": "DEFAULT_GROUP",
    "Namespace": "3baec428-9669-486c-b359-a76f7a1f1ac7" //product
  },
  "xxlJob": {
    "adminAddresses": "http://xxl-job-admin.base:8080/xxl-job-admin" //正式环境改为线上:"adminAddresses": "http://job.mall3s.com/xxl-job-admin",
  }
}
  • 添加测试环境配置 appsettings.Test.json
{
  "NacosConfig": { //nacos
    "ServerAddresses": [ "http://nacos.base:30099" ],
    "GroupName": "DEFAULT_GROUP",
    "Namespace": "1e017954-eb52-4d21-a843-0286d9013cf3" //test
  },
  "xxlJob": {
    "adminAddresses": "http://xxl-job-admin.base:8080/xxl-job-admin" //正式环境改为线上:"adminAddresses": "http://job.mall3s.com/xxl-job-admin",
  }
}
  1. startup加入2个微服务库
  public IConfiguration Configuration { get; }
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }
public void ConfigureServices(IServiceCollection services)
            {
           
                //添加微服务
                services.AddMicroService(Configuration);
                services.AddRazorPages();
            }
          public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
            }
            //注入mall3s微服务
            app.UseMicroService(Configuration);

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapRazorPages();
            });
        }
  1. program需要引用微服务
 webBuilder.Inject().UseMicroService().UseStartup<Startup>();
  1. Startup.cs
public void ConfigureServices(IServiceCollection services)
        {
            //除了默认DB和租户DB外的DB
#if DEBUG
             //添加微服务(debug不验证token)
            services.AddMicroService(_configuration, false);
#else
             //添加微服务
            services.AddMicroService(_configuration, true);
#endif

            //注册IHttp服务请求,微服务调用
            //services.AddNacosDiscoveryTypedClient<ITestHttpApi>("mall3s-system", "DEFAULT");

            services.AddRazorPages();
            //翻译
            //services.AddTranslate(_configuration);
            //七牛云
            //services.AddQiNiu(_configuration);
            //任务管理
            services.AddXxlJobAuto(_configuration);

        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
            }
            //注入mall3s微服务
            app.UseMicroService(_configuration);

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapRazorPages();
            });
            //xxljob
            app.UseXxlJobExecutor();

        }

备注:关键代码:services.AddMicroService(_configuration, false);

添加微服务后即可自动加入数据库租户信息

微服务有四个实现,下面两个为自定义扩展库,可灵活选择

//实现一:nacos配置好DB链接信息,直接告知微服务需要额外加入的DB的key
public static IServiceCollection AddMicroService(this IServiceCollection services, IConfiguration configuration, bool enableGlobalAuthorize = false, List<string> connKeys = null)

//实现二:自行配置DB配置后赋值
public static IServiceCollection AddMicroService(this IServiceCollection services, IConfiguration configuration,bool enableGlobalAuthorize= false, List<ConnectionConfig> connectionConfigs = null)

创建新增删改查接口 #

Mall3s微服务不需要单独创建Controller,一个标准化的增删改查只需要创建三个工程:

  • Mall3s.Service层
  • Mall3s.Entities层
  • Mall3s.Interfaces层

img

Mall3s.Entities实体层 #

Entity层主要分为5类:

  • Dto实体

用于与UI层交互的模型层。

命名规则主要是xxxInput,xxxxOutput分别代表输入和输出返回,并需要设置[SuppressSniffer]不被服务注册扫描到该实体。

  • Entity实体

用于数据库交互的实体模型。

  1. 统一继承Mall3sEntityBase接口,其中Mall3sEntityBase包含基础字段。
  2. 需要设置SugarTable标签,代表映射数据库哪个表[SugarTable("skums_category")]
  3. 需要设置租户标签**[Tenant("mall3s_skums")]**
 /// <summary>
    /// 类目管理
    /// </summary>
    [SugarTable("skums_category")]
    [Tenant("mall3s_skums")]
    public class SkumsCategoryEntity:Mall3sEntityBase
    {
      
       
        /// <summary>
        /// 类目中文名
        /// </summary>
        [SugarColumn(ColumnName = "category_cn_name")]        
        public string CategoryCnName { get; set; }
        
        /// <summary>
        /// 类目英文名
        /// </summary>
        [SugarColumn(ColumnName = "category_en_name")]        
        public string CategoryEnName { get; set; }
        
        /// <summary>
        /// 完整类目路径
        /// </summary>
        [SugarColumn(ColumnName = "category_cn_path")]        
        public string CategoryCnPath { get; set; }
        
        /// <summary>
        /// 上级分类Id
        /// </summary>
        [SugarColumn(ColumnName = "parent_id")]        
        public string ParentId { get; set; }
        
        /// <summary>
        /// 上级分类
        /// </summary>
        [SugarColumn(ColumnName = "parent_category_cn_name")]        
        public string ParentCategoryCnName { get; set; }
        
        /// <summary>
        /// 海关编码
        /// </summary>
        [SugarColumn(ColumnName = "customs_code")]        
        public string CustomsCode { get; set; }
        
        /// <summary>
        /// 申报品名
        /// </summary>
        [SugarColumn(ColumnName = "declared_product_name")]        
        public string DeclaredProductName { get; set; }
        
        /// <summary>
        /// 产品性质
        /// </summary>
        [SugarColumn(ColumnName = "product_nature")]        
        public string ProductNature { get; set; }
        
        /// <summary>
        /// 最低申报价(美元)
        /// </summary>
        [SugarColumn(ColumnName = "minimum_declare_price")]        
        public decimal MinimumDeclaraPrice { get; set; }
        
    }
  • Model实体

各个层交互的模型实体。

  • Mapper

用于定义Dto或者Model与Entity数据库映射的模型。

  1. 需继承IRegister接口
  2. 需要定义Register方法,用于设置Mapping规则。
public class Mapper : IRegister
    {
        public void Register(TypeAdapterConfig config)
        {
            config.ForType<IMContentEntity, IMUnreadNumModel>()
                .Map(dest => dest.unreadNum, src => src.State);
            config.ForType<UserOnlineModel, OnlineUserListOutput>()
              .Map(dest => dest.userId, src => src.userId)
              .Map(dest => dest.userAccount, src => src.account)
              .Map(dest => dest.userName, src => src.userName)
              .Map(dest => dest.loginTime, src => src.lastTime)
              .Map(dest => dest.loginIPAddress, src => src.lastLoginIp)
              .Map(dest => dest.loginPlatForm, src => src.lastLoginPlatForm);
        }
    }

Mall3s.Service层 #

  • 新建Service继承IDynamicApiController和ITransient,将会动态注入API服务并完成自动注册。

  • 通过ApiDescrtionsettings设置接口文档参数信息。

  • 通过Route设置API路由信息

 /// <summary>
    /// 系统消息
    /// 版 本:V3.2
    /// 版 权:mall3s开发
    /// 作 者:Mall3s开发平台组
    /// 日 期:2021-06-01 
    /// </summary>
    [ApiDescriptionSettings(Tag = "Message", Name = "message", Order = 240)]
    [Route("api/[controller]")]
    public class MessageService : IMessageService, IDynamicApiController, ITransient
    {
    }
    }
  • 标准化RESTFULL风格的接口,请遵循标准。

  • 列表分页采用Get请求,更新采用PUT,删除采用DELETE,新建采用POST。

  • 返回值尽量采用Dynamic返回。

  • 不对外提供API请设置 [NonAction]标签。

   /// <summary>
        /// 获取类目管理
        /// </summary>
        /// <param name="id">参数</param>
        /// <returns></returns>
        [HttpGet("{id}")]
        public async Task<dynamic> GetInfo(string id)
        {
            var entity = await _db.Queryable<SkumsCategoryEntity>().FirstAsync(p => p.Id == id);
            var output = entity.Adapt<SkumsCategoryInfoOutput>();
            return output;
        }

        /// <summary>
		/// 获取类目管理列表
		/// </summary>
		/// <param name="input">请求参数</param>
		/// <returns></returns>
        [HttpGet("")]
        public async Task<dynamic> GetList([FromQuery] SkumsCategoryListQueryInput input)
        {
            var sidx = input.sidx == null ? "id" : input.sidx;
            var data = await _db.Queryable<SkumsCategoryEntity>()
                .WhereIF(!string.IsNullOrEmpty(input.categoryCnName), p => p.CategoryCnName.Contains(input.categoryCnName))
                .WhereIF(!string.IsNullOrEmpty(input.categoryEnName), p => p.CategoryEnName.Contains(input.categoryEnName))
                .WhereIF(!string.IsNullOrEmpty(input.parentCategoryCnName), p => p.ParentCategoryCnName.Contains(input.parentCategoryCnName))
                .Select(it=> new SkumsCategoryListOutput
                {
                    id = it.Id,
                    categoryCnName=it.CategoryCnName,
                    categoryEnName=it.CategoryEnName,
                    parentCategoryCnName=it.ParentCategoryCnName,
                    customsCode=it.CustomsCode,
                    declaredProductName=it.DeclaredProductName,
                    productNature=it.ProductNature,
                    minimumDeclaraPrice=it.MinimumDeclaraPrice,
                }).MergeTable().OrderBy(sidx+" "+input.sort).ToPagedListAsync(input.currentPage, input.pageSize);
                return PageResult<SkumsCategoryListOutput>.SqlSugarPageResult(data);
        }

        /// <summary>
        /// 新建类目管理
        /// </summary>
        /// <param name="input">参数</param>
        /// <returns></returns>
        [HttpPost("")]
        public async Task Create([FromBody] SkumsCategoryCrInput input)
        {
            var userInfo = await _userManager.GetUserInfo();
            var entity = input.Adapt<SkumsCategoryEntity>();
            entity.Id = YitIdHelper.NextId().ToString();
            var isOk = await _db.Insertable(entity).IgnoreColumns(ignoreNullColumn: true).ExecuteCommandAsync();
            if (!(isOk > 0)) throw Mall3sException.Oh(ErrorCode.COM1000);
        }
        /// <summary>
		/// 获取类目管理无分页列表
		/// </summary>
		/// <param name="input">请求参数</param>
		/// <returns></returns>
        [NonAction]
        public async Task<dynamic> GetNoPagingList([FromQuery] SkumsCategoryListQueryInput input)
        {
            var sidx = input.sidx == null ? "id" : input.sidx;
            var data = await _db.Queryable<SkumsCategoryEntity>()
                .WhereIF(!string.IsNullOrEmpty(input.categoryCnName), p => p.CategoryCnName.Contains(input.categoryCnName))
                .WhereIF(!string.IsNullOrEmpty(input.categoryEnName), p => p.CategoryEnName.Contains(input.categoryEnName))
                .WhereIF(!string.IsNullOrEmpty(input.parentCategoryCnName), p => p.ParentCategoryCnName.Contains(input.parentCategoryCnName))
                .Select(it => new SkumsCategoryListOutput
                {
                    id = it.Id,
                    categoryCnName = it.CategoryCnName,
                    categoryEnName = it.CategoryEnName,
                    parentCategoryCnName = it.ParentCategoryCnName,
                    customsCode = it.CustomsCode,
                    declaredProductName = it.DeclaredProductName,
                    productNature = it.ProductNature,
                    minimumDeclaraPrice = it.MinimumDeclaraPrice,
                }).MergeTable().OrderBy(sidx + " " + input.sort).ToListAsync();
            return data;
        }

        /// <summary>
		/// 导出类目管理
		/// </summary>
		/// <param name="input">请求参数</param>
		/// <returns></returns>
        [HttpGet("Actions/Export")]
        public async Task<dynamic> Export([FromQuery] SkumsCategoryListQueryInput input)
        {
            var userInfo = await _userManager.GetUserInfo();
            var exportData = new List<SkumsCategoryListOutput>();
            if (input.dataType == 0)
            {
                var data = Clay.Object(await this.GetList(input));
                exportData = data.Solidify<PageResult<SkumsCategoryListOutput>>().list;
            }
            else
            {
                exportData = await this.GetNoPagingList(input);
            }
            List<ParamsModel> paramList = "[{\"value\":\"类目中文名\",\"field\":\"categoryCnName\"},{\"value\":\"类目英文名\",\"field\":\"categoryEnName\"},{\"value\":\"上级分类\",\"field\":\"parentCategoryCnName\"},{\"value\":\"海关编码\",\"field\":\"customsCode\"},{\"value\":\"申报品名\",\"field\":\"declaredProductName\"},{\"value\":\"产品性质\",\"field\":\"productNature\"},{\"value\":\"最低申报价(美元)\",\"field\":\"minimumDeclaraPrice\"},]".ToList<ParamsModel>();
            ExcelConfig excelconfig = new ExcelConfig();
            excelconfig.FileName = DateTime.Now.ToString("yyyyMMdd") + "_" + "类目管理.xls";
            excelconfig.HeadFont = "微软雅黑";
            excelconfig.HeadPoint = 10;
            excelconfig.IsAllSizeColumn = true;
            excelconfig.ColumnModel = new List<ExcelColumnModel>();
            List<string> selectKeyList = input.selectKey.Split(',').ToList();
            foreach (var item in selectKeyList)
            {
                var isExist = paramList.Find(p => p.field == item);
                if (isExist != null)
                {
                    excelconfig.ColumnModel.Add(new ExcelColumnModel() { Column = isExist.field, ExcelColumn = isExist.value });
                }
            }
            if (!Directory.Exists(FileVariable.TemporaryFilePath))
                Directory.CreateDirectory(FileVariable.TemporaryFilePath);
            var addPath = FileVariable.TemporaryFilePath + excelconfig.FileName;
            ExcelExportHelper<SkumsCategoryListOutput>.Export(exportData, excelconfig, addPath);
            FileInfo file = new FileInfo(addPath);
            FileStream fs = file.OpenRead();
            BinaryReader br = new BinaryReader(fs);
            byte[] bytes = br.ReadBytes((int)fs.Length);
            Stream stream = new MemoryStream(bytes);
            fs.Close(); 

             var _fileName = DateTime.Now.ToString("yyyyMMdd") + "_" + YitIdHelper.NextId().ToString() + Path.GetExtension(file.Name);
            var bucketName = KeyVariable.BucketName;
            var _filePath = "storage/TemporaryFile/";
            var uploadPath = Path.Combine(_filePath, _fileName);
            var result = await _oSSServiceFactory.Create().PutObjectAsync(bucketName, uploadPath, stream);
            var qiniuUrl = App.Configuration["QiNiu:CdnUrl"] + "/" + uploadPath;
            if (File.Exists(addPath))
            {
                File.Delete(addPath);
            }
            var output = new
            {
                name = excelconfig.FileName,
                url = qiniuUrl
            };
            return output;
        }
        /// <summary>
        /// 更新类目管理
        /// </summary>
        /// <param name="id">主键</param>
        /// <param name="input">参数</param>
        /// <returns></returns>
        [HttpPut("{id}")]
        public async Task Update(string id, [FromBody] SkumsCategoryUpInput input)
        {
            var entity = input.Adapt<SkumsCategoryEntity>();
            var isOk = await _db.Updateable(entity).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync();
            if (!(isOk > 0)) throw Mall3sException.Oh(ErrorCode.COM1001);
        }

        /// <summary>
        /// 删除类目管理
        /// </summary>
        /// <returns></returns>
        [HttpDelete("{id}")]
        public async Task Delete(string id)
        {
            var entity = await _db.Queryable<SkumsCategoryEntity>().FirstAsync(p => p.Id == id);
            _ = entity ?? throw Mall3sException.Oh(ErrorCode.COM1005);
            var isOk = await _db.Deleteable<SkumsCategoryEntity>().Where(d => d.Id == id).ExecuteCommandAsync();
            if (!(isOk > 0)) throw Mall3sException.Oh(ErrorCode.COM1002);
        }
       
        /// <summary>
        /// 列表
        /// </summary>
        /// <returns></returns>
        [HttpGet("Selector")]
        public async Task<dynamic> GetSelector()
        {
            var data = await GetList();
            var output = data.Adapt<List<CategoryTypeSelectorOutput>>();
            return new { list = output.ToTree("-1") };
        }
        #region PublicMethod
        /// <summary>
        /// 列表
        /// </summary>
        /// <returns></returns>
        [NonAction]
        public async Task<List<SkumsCategoryEntity>> GetList()
        {
            return await _skumsCategoryRepository.Entities.OrderBy(x => x.CreatorTime, OrderByType.Desc).ToListAsync();
        }
        #endregion

Mall3s.Interfaces接口层 #

接口层主要是为Service层提供接口调用。

namespace Mall3s.SubDev.Interfaces.SkumsCategory
{
    public interface ISkumsCategoryService
    {
    }
}

第一个增删改查案例 #

业务需求:完成一个简单的增删改查模块教学。

主要流程如下:

  1. 数据库建模。设计数据库结构。

  2. 使用低代码功能设计界面。使用代码生成器设计界面。

  3. 下载源码并放到项目中。生成代码后,下载源码并将前后端分别到对应项目工程中。

  4. 运行。启动前后端项目。

  5. 配置菜单并上线。配置菜单,功能上线。

一、建立数据库模型 #

数据库设计建模 #

可使用powerdesigner或者pdMan做数据库结构设计。

Mall3s数据管理创建数据表。 #

配置数据库链接。添加自己的数据库。

img

数据建模。 #

新建数据库表以及字段并保存。

img

二、使用低代码功能设计界面 #

代码生成器生成代码 #

打开http://test.mall3s.com/generator/webForm,选择功能表单-》新建模板,根据业务场景,选择表单,列表,流程多个模式生成代码。

img

根据教程配置模块。

img

三、下载源码并放到项目中 #

下载源代码。涉及前端的页面主要包括三个部分:

  • index.vue页面
  • ExportBox.vue页面
  • Form.vue页面

复制代码到/src/views/xxx/yyyy目录下(xxx代表子系统的名称,yyyy代表模块名称)

涉及后端的代码,分别放到您项目模块中以下目录:

  • Mall3s.Entities实体层

  • Mall3s.Interface接口层

  • Mall3s.Service服务层

  • Mall3s.WebApi接口层

四、运行 #

  • 前端执行npm run dev
  • 后端用vs 2019启动项目即可

五、配置菜单功能上线 #

打开菜单 http://web.mall3s.com/system/menu

选择类型:页面,设置地址即可。

img

预览效果:

image-20230302011234426

上次更新: 3/1/2023, 5:20:19 PM